initial commit

This commit is contained in:
RyanOnTheInside
2025-01-16 21:30:24 -05:00
commit cf21ebd88d
69 changed files with 4067 additions and 0 deletions
+93
View File
@@ -0,0 +1,93 @@
# Python cache files
__pycache__/
*.py[cod]
*$py.class
**/*.pyc
**/__pycache__
**/.pytest_cache
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
data/
# PyInstaller
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
# Jupyter Notebook
.ipynb_checkpoints
# pyenv
.python-version
# Environment directories
.env
.venv
env/
venv/
ENV/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
# IDE settings
.vscode/
.idea/
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
*.log
# ComfyUI specific (adjust as needed)
# output/
# input/
# models/
+110
View File
@@ -0,0 +1,110 @@
# ComfyUI ProfilerX
A performance profiling suite for ComfyUI that automatically tracks execution time, memory usage, and cache performance of your workflows.
## Features
- 🔄 Real-time monitoring of workflow execution
- 📊 Memory usage tracking (VRAM and RAM)
- ⚡ Node execution time breakdown
- 💾 Cache hit/miss statistics
- 📈 Beautiful, interactive charts and tables
- 🎯 Zero configuration required
- 📱 Responsive UI that integrates with ComfyUI's interface
- 📊 Historical data tracking and analysis
- ⚙️ Time-range filtering for analytics
- 🔍 Sortable performance tables
## Requirements
- ComfyUI (latest version recommended)
- Python 3.8+
- CUDA-capable GPU (for VRAM monitoring)
- Modern web browser
## Installation
### Option 1: ComfyUI Manager (Recommended)
1. Install [ComfyUI Manager](https://github.com/ltdrdata/ComfyUI-Manager)
2. Use the Manager's interface to install "ComfyUI ProfilerX"
3. Restart ComfyUI
### Option 2: Manual Installation
1. Clone this repository into your `custom_nodes` directory:
```bash
cd ComfyUI/custom_nodes
git clone https://github.com/ryanontheinside/ComfyUI_ProfilerX
```
2. Install the required Python package:
```bash
cd ComfyUI_ProfilerX
pip install -r requirements.txt
```
3. Restart ComfyUI
## Usage
The profiler integrates directly into ComfyUI's interface:
1. After installation, you'll see a new performance monitoring panel in the top-right corner
2. Run your workflows as normal
3. The dashboard will automatically update with:
- Total execution time
- Peak memory usage (VRAM and RAM)
- Cache performance metrics
- Per-node execution time breakdown
- Historical performance trends
<img src="https://github.com/user-attachments/assets/4dd514a4-8d52-4047-9f2c-3588a669e2c9" alt="ProfilerX Interface" width="600">
The profiler runs automatically in the background, collecting data for every workflow execution. No additional configuration is needed.
## Features in Detail
### Real-time Monitoring
- Live execution time tracking
- Memory usage graphs (VRAM and RAM)
- Cache hit/miss counters
- Node-by-node progress tracking
### Analytics Dashboard
- Historical performance trends
- Node execution time breakdown
- Memory usage patterns
- Cache efficiency analysis
- Time-range filtering
- Sortable performance tables
### Data Management
- Automatic history tracking
- Data persistence between sessions
- Archive management
- Export capabilities
## How it Works
ProfilerX integrates directly with ComfyUI's execution system to collect performance metrics:
- Execution time is measured for both individual nodes and the entire workflow
- Memory usage is tracked using `torch.cuda` for VRAM and `psutil` for RAM
- Cache performance is monitored by intercepting ComfyUI's caching system
- All data is collected automatically with minimal performance impact
- Historical data is stored locally for trend analysis
## Other Projects by RyanOnTheInside
Check out my other ComfyUI custom nodes:
- [ComfyUI_RyanOnTheInside](https://github.com/RyanOnTheInside/ComfyUI_RyanOnTheInside) - Everything Reactivity
- [ComfyUI_RealTimeNodes](https://github.com/RyanOnTheInside/ComfyUI_RealTimeNodes) - Real-Time ComfyUI Use Cases
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request. For major changes, please open an issue first to discuss what you would like to change.
## License
MIT License - feel free to use this in your own projects!
+29
View File
@@ -0,0 +1,29 @@
"""Profiler extension for ComfyUI"""
import os
import logging
from .prestartup import inject_profiling, PROFILER_ENABLED # Import but don't auto-inject
from . import server # Register API routes
logger = logging.getLogger('ComfyUI-ProfilerX')
logger.setLevel(logging.ERROR)
# Set up web directory
WEB_DIRECTORY = "./web"
# Try to inject profiling hooks
if not inject_profiling():
# If injection fails, remove the profiler node from available nodes
logger.warning("Disabling profiler nodes due to initialization failure")
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
def setup_js():
"""Register web extension with profiler status"""
return {
"name": "ComfyUI-ProfilerX",
"module": "index.js",
"enabled": PROFILER_ENABLED,
"status": "active" if PROFILER_ENABLED else "disabled"
}
Generated Vendored
+16
View File
@@ -0,0 +1,16 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../typescript/bin/tsc" "$@"
else
exec node "$basedir/../typescript/bin/tsc" "$@"
fi
Generated Vendored
+17
View File
@@ -0,0 +1,17 @@
@ECHO off
GOTO start
:find_dp0
SET dp0=%~dp0
EXIT /b
:start
SETLOCAL
CALL :find_dp0
IF EXIST "%dp0%\node.exe" (
SET "_prog=%dp0%\node.exe"
) ELSE (
SET "_prog=node"
SET PATHEXT=%PATHEXT:;.JS;=;%
)
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\typescript\bin\tsc" %*
Generated Vendored
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env pwsh
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "$basedir/node$exe" "$basedir/../typescript/bin/tsc" $args
} else {
& "$basedir/node$exe" "$basedir/../typescript/bin/tsc" $args
}
$ret=$LASTEXITCODE
} else {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "node$exe" "$basedir/../typescript/bin/tsc" $args
} else {
& "node$exe" "$basedir/../typescript/bin/tsc" $args
}
$ret=$LASTEXITCODE
}
exit $ret
Generated Vendored
+16
View File
@@ -0,0 +1,16 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../typescript/bin/tsserver" "$@"
else
exec node "$basedir/../typescript/bin/tsserver" "$@"
fi
+17
View File
@@ -0,0 +1,17 @@
@ECHO off
GOTO start
:find_dp0
SET dp0=%~dp0
EXIT /b
:start
SETLOCAL
CALL :find_dp0
IF EXIST "%dp0%\node.exe" (
SET "_prog=%dp0%\node.exe"
) ELSE (
SET "_prog=node"
SET PATHEXT=%PATHEXT:;.JS;=;%
)
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\typescript\bin\tsserver" %*
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env pwsh
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "$basedir/node$exe" "$basedir/../typescript/bin/tsserver" $args
} else {
& "$basedir/node$exe" "$basedir/../typescript/bin/tsserver" $args
}
$ret=$LASTEXITCODE
} else {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "node$exe" "$basedir/../typescript/bin/tsserver" $args
} else {
& "node$exe" "$basedir/../typescript/bin/tsserver" $args
}
$ret=$LASTEXITCODE
}
exit $ret
+22
View File
@@ -0,0 +1,22 @@
{
"name": "comfyui-profilerx",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"node_modules/typescript": {
"version": "5.7.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
"integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}
+55
View File
@@ -0,0 +1,55 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
You must give any other recipients of the Work or Derivative Works a copy of this License; and
You must cause any modified files to carry prominent notices stating that You changed the files; and
You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
+50
View File
@@ -0,0 +1,50 @@
# TypeScript
[![GitHub Actions CI](https://github.com/microsoft/TypeScript/workflows/CI/badge.svg)](https://github.com/microsoft/TypeScript/actions?query=workflow%3ACI)
[![npm version](https://badge.fury.io/js/typescript.svg)](https://www.npmjs.com/package/typescript)
[![Downloads](https://img.shields.io/npm/dm/typescript.svg)](https://www.npmjs.com/package/typescript)
[![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/microsoft/TypeScript/badge)](https://securityscorecards.dev/viewer/?uri=github.com/microsoft/TypeScript)
[TypeScript](https://www.typescriptlang.org/) is a language for application-scale JavaScript. TypeScript adds optional types to JavaScript that support tools for large-scale JavaScript applications for any browser, for any host, on any OS. TypeScript compiles to readable, standards-based JavaScript. Try it out at the [playground](https://www.typescriptlang.org/play/), and stay up to date via [our blog](https://blogs.msdn.microsoft.com/typescript) and [Twitter account](https://twitter.com/typescript).
Find others who are using TypeScript at [our community page](https://www.typescriptlang.org/community/).
## Installing
For the latest stable version:
```bash
npm install -D typescript
```
For our nightly builds:
```bash
npm install -D typescript@next
```
## Contribute
There are many ways to [contribute](https://github.com/microsoft/TypeScript/blob/main/CONTRIBUTING.md) to TypeScript.
* [Submit bugs](https://github.com/microsoft/TypeScript/issues) and help us verify fixes as they are checked in.
* Review the [source code changes](https://github.com/microsoft/TypeScript/pulls).
* Engage with other TypeScript users and developers on [StackOverflow](https://stackoverflow.com/questions/tagged/typescript).
* Help each other in the [TypeScript Community Discord](https://discord.gg/typescript).
* Join the [#typescript](https://twitter.com/search?q=%23TypeScript) discussion on Twitter.
* [Contribute bug fixes](https://github.com/microsoft/TypeScript/blob/main/CONTRIBUTING.md).
This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). For more information see
the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [opencode@microsoft.com](mailto:opencode@microsoft.com)
with any additional questions or comments.
## Documentation
* [TypeScript in 5 minutes](https://www.typescriptlang.org/docs/handbook/typescript-in-5-minutes.html)
* [Programming handbook](https://www.typescriptlang.org/docs/handbook/intro.html)
* [Homepage](https://www.typescriptlang.org/)
## Roadmap
For details on our planned features and future direction, please refer to our [roadmap](https://github.com/microsoft/TypeScript/wiki/Roadmap).
+41
View File
@@ -0,0 +1,41 @@
<!-- BEGIN MICROSOFT SECURITY.MD V0.0.9 BLOCK -->
## Security
Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/Microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet) and [Xamarin](https://github.com/xamarin).
If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://aka.ms/security.md/definition), please report it to us as described below.
## Reporting Security Issues
**Please do not report security vulnerabilities through public GitHub issues.**
Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://aka.ms/security.md/msrc/create-report).
If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://aka.ms/security.md/msrc/pgp).
You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://www.microsoft.com/msrc).
Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue:
* Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.)
* Full paths of source file(s) related to the manifestation of the issue
* The location of the affected source code (tag/branch/commit or direct URL)
* Any special configuration required to reproduce the issue
* Step-by-step instructions to reproduce the issue
* Proof-of-concept or exploit code (if possible)
* Impact of the issue, including how an attacker might exploit the issue
This information will help us triage your report more quickly.
If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://aka.ms/security.md/msrc/bounty) page for more details about our active programs.
## Preferred Languages
We prefer all communications to be in English.
## Policy
Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://aka.ms/security.md/cvd).
<!-- END MICROSOFT SECURITY.MD BLOCK -->
File diff suppressed because one or more lines are too long
+2
View File
@@ -0,0 +1,2 @@
#!/usr/bin/env node
require('../lib/tsc.js')
+2
View File
@@ -0,0 +1,2 @@
#!/usr/bin/env node
require('../lib/tsserver.js')
+120
View File
@@ -0,0 +1,120 @@
{
"name": "typescript",
"author": "Microsoft Corp.",
"homepage": "https://www.typescriptlang.org/",
"version": "5.7.3",
"license": "Apache-2.0",
"description": "TypeScript is a language for application scale JavaScript development",
"keywords": [
"TypeScript",
"Microsoft",
"compiler",
"language",
"javascript"
],
"bugs": {
"url": "https://github.com/microsoft/TypeScript/issues"
},
"repository": {
"type": "git",
"url": "https://github.com/microsoft/TypeScript.git"
},
"main": "./lib/typescript.js",
"typings": "./lib/typescript.d.ts",
"bin": {
"tsc": "./bin/tsc",
"tsserver": "./bin/tsserver"
},
"engines": {
"node": ">=14.17"
},
"files": [
"bin",
"lib",
"!lib/enu",
"LICENSE.txt",
"README.md",
"SECURITY.md",
"ThirdPartyNoticeText.txt",
"!**/.gitattributes"
],
"devDependencies": {
"@dprint/formatter": "^0.4.1",
"@dprint/typescript": "0.93.0",
"@esfx/canceltoken": "^1.0.0",
"@eslint/js": "^9.11.1",
"@octokit/rest": "^21.0.2",
"@types/chai": "^4.3.20",
"@types/diff": "^5.2.2",
"@types/minimist": "^1.2.5",
"@types/mocha": "^10.0.8",
"@types/ms": "^0.7.34",
"@types/node": "latest",
"@types/source-map-support": "^0.5.10",
"@types/which": "^3.0.4",
"@typescript-eslint/rule-tester": "^8.8.0",
"@typescript-eslint/type-utils": "^8.8.0",
"@typescript-eslint/utils": "^8.8.0",
"azure-devops-node-api": "^14.1.0",
"c8": "^10.1.2",
"chai": "^4.5.0",
"chalk": "^4.1.2",
"chokidar": "^3.6.0",
"diff": "^5.2.0",
"dprint": "^0.47.2",
"esbuild": "^0.24.0",
"eslint": "^9.11.1",
"eslint-formatter-autolinkable-stylish": "^1.4.0",
"eslint-plugin-regexp": "^2.6.0",
"fast-xml-parser": "^4.5.0",
"glob": "^10.4.5",
"globals": "^15.9.0",
"hereby": "^1.10.0",
"jsonc-parser": "^3.3.1",
"knip": "^5.30.6",
"minimist": "^1.2.8",
"mocha": "^10.7.3",
"mocha-fivemat-progress-reporter": "^0.1.0",
"monocart-coverage-reports": "^2.11.0",
"ms": "^2.1.3",
"playwright": "^1.47.2",
"source-map-support": "^0.5.21",
"tslib": "^2.7.0",
"typescript": "^5.6.2",
"typescript-eslint": "^8.8.0",
"which": "^3.0.1"
},
"overrides": {
"typescript@*": "$typescript"
},
"scripts": {
"test": "hereby runtests-parallel --light=false",
"test:eslint-rules": "hereby run-eslint-rules-tests",
"build": "npm run build:compiler && npm run build:tests",
"build:compiler": "hereby local",
"build:tests": "hereby tests",
"build:tests:notypecheck": "hereby tests --no-typecheck",
"clean": "hereby clean",
"gulp": "hereby",
"lint": "hereby lint",
"knip": "hereby knip",
"format": "dprint fmt",
"setup-hooks": "node scripts/link-hooks.mjs"
},
"browser": {
"fs": false,
"os": false,
"path": false,
"crypto": false,
"buffer": false,
"source-map-support": false,
"inspector": false,
"perf_hooks": false
},
"packageManager": "npm@8.19.4",
"volta": {
"node": "20.1.0",
"npm": "8.19.4"
},
"gitHead": "a5e123d9e0690fcea92878ea8a0a382922009fc9"
}
+29
View File
@@ -0,0 +1,29 @@
{
"name": "comfyui-profilerx",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "comfyui-profilerx",
"version": "1.0.0",
"devDependencies": {
"typescript": "^5.3.3"
}
},
"node_modules/typescript": {
"version": "5.7.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
"integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "comfyui-profilerx",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "tsc",
"watch": "tsc --watch",
"tsc": "tsc"
},
"devDependencies": {
"typescript": "^5.3.3"
}
}
+109
View File
@@ -0,0 +1,109 @@
"""Prestartup script to inject profiling hooks into ComfyUI's execution system"""
import logging
import sys
import os
import functools
# Add ComfyUI root to path
comfy_path = os.path.dirname(os.path.dirname(os.path.dirname(os.path.realpath(__file__))))
if comfy_path not in sys.path:
sys.path.append(comfy_path)
import execution
import server
from .profiler_core import ProfilerManager
logger = logging.getLogger('ComfyUI-ProfilerX')
logger.setLevel(logging.ERROR)
# Store original functions
original_execute = execution.execute
original_ExecutionList_init = execution.ExecutionList.__init__
original_PromptExecutor_execute = execution.PromptExecutor.execute
# Global flag to track if profiler is enabled
PROFILER_ENABLED = False
def ExecutionList_init_with_profiling(self, *args, **kwargs):
"""Start profiling when a new execution begins"""
original_ExecutionList_init(self, *args, **kwargs)
def PromptExecutor_execute_with_profiling(self, prompt, prompt_id, extra_data={}, execute_outputs=[]):
"""Start profiling when a new workflow begins"""
if not PROFILER_ENABLED:
return original_PromptExecutor_execute(self, prompt, prompt_id, extra_data, execute_outputs)
logger.debug(f"Starting workflow profiling for new execution: {prompt_id}")
profiler = ProfilerManager.get_instance()
profiler.start_workflow(prompt_id)
try:
return original_PromptExecutor_execute(self, prompt, prompt_id, extra_data, execute_outputs)
finally:
logger.debug(f"Workflow complete, ending profiling for {prompt_id}")
profiler.end_workflow(prompt_id)
def execute_with_profiling(server, dynprompt, caches, current_item, extra_data, executed, prompt_id, execution_list, pending_subgraph_results):
"""Minimal wrapper around execute to collect profiling data"""
if not PROFILER_ENABLED or not prompt_id:
return original_execute(server, dynprompt, caches, current_item, extra_data, executed, prompt_id, execution_list, pending_subgraph_results)
try:
node = dynprompt.get_node(current_item)
node_id = dynprompt.get_real_node_id(current_item)
node_type = node['class_type']
inputs = node['inputs']
logger.debug(f"Profiling node execution - id: {node_id}, type: {node_type}")
except Exception as e:
logger.error(f"Failed to get node info: {e}")
return original_execute(server, dynprompt, caches, current_item, extra_data, executed, prompt_id, execution_list, pending_subgraph_results)
profiler = ProfilerManager.get_instance()
try:
# Start profiling this node
profiler.start_node(prompt_id, node_id, node_type, inputs)
cache_hit = caches.outputs.get(current_item) is not None
logger.debug(f"Node {node_id} cache hit: {cache_hit}")
# Execute node
result = original_execute(server, dynprompt, caches, current_item, extra_data, executed, prompt_id, execution_list, pending_subgraph_results)
# End profiling
outputs = caches.outputs.get(current_item)
if outputs is None:
outputs = {}
profiler.end_node(prompt_id, node_id, outputs, cache_hit)
return result
except Exception as e:
logger.error(f"Error during node execution: {e}")
profiler.record_error(prompt_id, node_id, str(e))
# Don't re-raise - let ComfyUI handle the error
return original_execute(server, dynprompt, caches, current_item, extra_data, executed, prompt_id, execution_list, pending_subgraph_results)
def inject_profiling():
"""Inject minimal profiling hook"""
global PROFILER_ENABLED
logger.info("Attempting to inject profiling hooks...")
try:
# Verify we can access all required ComfyUI internals
if not hasattr(execution, 'execute') or not hasattr(execution, 'ExecutionList') or not hasattr(execution, 'PromptExecutor'):
raise ImportError("Required ComfyUI execution components not found - incompatible ComfyUI version?")
# Store originals and inject our wrapped versions
execution.execute = execute_with_profiling
execution.ExecutionList.__init__ = ExecutionList_init_with_profiling
execution.PromptExecutor.execute = PromptExecutor_execute_with_profiling
PROFILER_ENABLED = True
logger.info("✓ Profiling hooks injected successfully")
return True
except Exception as e:
PROFILER_ENABLED = False
logger.error(f"❌ Failed to inject profiling hooks: {str(e)}")
logger.error("The profiler will be disabled for this session")
return False
# Don't auto-inject on import anymore - let __init__.py control this
+353
View File
@@ -0,0 +1,353 @@
import time
import psutil
import torch
import threading
import json
import os
import logging
from collections import defaultdict
from typing import Dict, List, Optional
logger = logging.getLogger('ComfyUI-ProfilerX')
logger.setLevel(logging.ERROR)
# Add console handler if not already added
if not logger.handlers:
ch = logging.StreamHandler()
ch.setLevel(logging.ERROR)
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
ch.setFormatter(formatter)
logger.addHandler(ch)
class ProfilerManager:
_instance = None
_lock = threading.Lock()
def __init__(self):
self.active_profiles: Dict[str, Dict] = {}
self.history: List[Dict] = []
self.max_history = 100
self.process = psutil.Process()
# Rolling averages for nodes and workflow
self.node_averages: Dict[str, Dict] = defaultdict(lambda: {
'total_time': 0.0,
'count': 0,
'vram_usage': 0.0,
'ram_usage': 0.0
})
self.workflow_averages = {
'total_time': 0.0,
'count': 0,
'vram_peak': 0.0,
'ram_peak': 0.0
}
# Create data directory if it doesn't exist
self.data_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "data")
os.makedirs(self.data_dir, exist_ok=True)
logger.debug(f"Data directory created/verified at: {self.data_dir}")
# Load existing history
self.history_file = os.path.join(self.data_dir, "profiling_history.json")
if os.path.exists(self.history_file):
try:
with open(self.history_file, 'r') as f:
self.history = json.load(f)
logger.debug(f"Loaded {len(self.history)} profiles from history file")
except Exception as e:
logger.error(f"Failed to load history file: {e}")
self.history = []
else:
logger.debug("No existing history file found")
self.history = []
def _update_node_average(self, node_type: str, execution_time: float, vram_used: float, ram_used: float) -> Dict:
"""Update rolling average for a node type"""
avg = self.node_averages[node_type]
avg['count'] += 1
avg['total_time'] = (avg['total_time'] * (avg['count'] - 1) + execution_time) / avg['count']
avg['vram_usage'] = (avg['vram_usage'] * (avg['count'] - 1) + vram_used) / avg['count']
avg['ram_usage'] = (avg['ram_usage'] * (avg['count'] - 1) + ram_used) / avg['count']
return avg
def _update_workflow_average(self, execution_time: float, vram_peak: float, ram_peak: float) -> Dict:
"""Update rolling average for workflow execution"""
self.workflow_averages['count'] += 1
self.workflow_averages['total_time'] = (
self.workflow_averages['total_time'] * (self.workflow_averages['count'] - 1) + execution_time
) / self.workflow_averages['count']
self.workflow_averages['vram_peak'] = (
self.workflow_averages['vram_peak'] * (self.workflow_averages['count'] - 1) + vram_peak
) / self.workflow_averages['count']
self.workflow_averages['ram_peak'] = (
self.workflow_averages['ram_peak'] * (self.workflow_averages['count'] - 1) + ram_peak
) / self.workflow_averages['count']
return self.workflow_averages
def _save_history(self):
"""Save profiling history to disk"""
try:
with open(self.history_file, 'w') as f:
json.dump(self.history[-self.max_history:], f, indent=2)
logger.debug(f"Saved {len(self.history)} profiles to history file")
except Exception as e:
logger.error(f"Failed to save history file: {e}")
@classmethod
def get_instance(cls) -> 'ProfilerManager':
if cls._instance is None:
with cls._lock:
if cls._instance is None:
cls._instance = cls()
logger.debug("Created new ProfilerManager instance")
return cls._instance
def start_workflow(self, prompt_id: str) -> None:
"""Start profiling a workflow execution"""
logger.debug(f"Starting workflow profiling for prompt_id: {prompt_id}")
self.active_profiles[prompt_id] = {
'promptId': prompt_id,
'startTime': time.time() * 1000, # Convert to ms
'nodes': {},
'executionOrder': [],
'totalVramPeak': 0,
'totalRamPeak': 0,
'cacheHits': 0,
'cacheMisses': 0
}
def end_workflow(self, prompt_id: str) -> Optional[Dict]:
"""End profiling a workflow execution"""
if prompt_id not in self.active_profiles:
logger.warning(f"Attempted to end non-existent workflow profile: {prompt_id}")
return None
logger.debug(f"Ending workflow profiling for prompt_id: {prompt_id}")
profile = self.active_profiles[prompt_id]
profile['endTime'] = time.time() * 1000
# Update peak memory usage
profile['totalVramPeak'] = torch.cuda.max_memory_allocated()
profile['totalRamPeak'] = self.process.memory_info().rss
# Calculate and update workflow averages
execution_time = profile['endTime'] - profile['startTime']
avg = self._update_workflow_average(execution_time, profile['totalVramPeak'], profile['totalRamPeak'])
profile['averages'] = {
'execution_time': avg['total_time'],
'vram_peak': avg['vram_peak'],
'ram_peak': avg['ram_peak'],
'count': avg['count']
}
# Store in history
self.history.append(profile)
if len(self.history) > self.max_history:
self.history.pop(0)
# Save to disk
self._save_history()
# Cleanup
del self.active_profiles[prompt_id]
return profile
def start_node(self, prompt_id: str, node_id: str, node_type: str, inputs: Dict) -> None:
"""Start profiling a node execution"""
if prompt_id not in self.active_profiles:
logger.warning(f"Attempted to start node profiling for non-existent workflow: {prompt_id}")
return
logger.debug(f"Starting node profiling - prompt: {prompt_id}, node: {node_id}, type: {node_type}")
profile = self.active_profiles[prompt_id]
profile['nodes'][node_id] = {
'nodeId': node_id,
'nodeType': node_type,
'startTime': time.time() * 1000,
'vramBefore': torch.cuda.memory_allocated(),
'ramBefore': self.process.memory_info().rss,
'inputSizes': self._get_tensor_sizes(inputs),
'outputSizes': {},
'cacheHit': False
}
profile['executionOrder'].append(node_id)
def end_node(self, prompt_id: str, node_id: str, outputs: Dict, cache_hit: bool = False) -> None:
"""End profiling a node execution"""
if prompt_id not in self.active_profiles:
logger.warning(f"Attempted to end node profiling for non-existent workflow: {prompt_id}")
return
if node_id not in self.active_profiles[prompt_id]['nodes']:
logger.warning(f"Attempted to end non-existent node profile: {node_id}")
return
logger.debug(f"Ending node profiling - prompt: {prompt_id}, node: {node_id}, cache_hit: {cache_hit}")
profile = self.active_profiles[prompt_id]
node = profile['nodes'][node_id]
node['endTime'] = time.time() * 1000
node['vramAfter'] = torch.cuda.memory_allocated()
node['ramAfter'] = self.process.memory_info().rss
node['outputSizes'] = self._get_tensor_sizes(outputs)
node['cacheHit'] = cache_hit
# Calculate and update averages
execution_time = node['endTime'] - node['startTime']
vram_used = node['vramAfter'] - node['vramBefore']
ram_used = node['ramAfter'] - node['ramBefore']
# Update rolling averages
avg = self._update_node_average(node['nodeType'], execution_time, vram_used, ram_used)
node['averages'] = {
'execution_time': avg['total_time'],
'vram_usage': avg['vram_usage'],
'ram_usage': avg['ram_usage'],
'count': avg['count']
}
if cache_hit:
profile['cacheHits'] += 1
else:
profile['cacheMisses'] += 1
def record_error(self, prompt_id: str, node_id: str, error: str) -> None:
"""Record an error that occurred during node execution"""
if prompt_id not in self.active_profiles:
logger.warning(f"Attempted to record error for non-existent workflow: {prompt_id}")
return
if node_id not in self.active_profiles[prompt_id]['nodes']:
logger.warning(f"Attempted to record error for non-existent node: {node_id}")
return
logger.error(f"Node error - prompt: {prompt_id}, node: {node_id}, error: {error}")
self.active_profiles[prompt_id]['nodes'][node_id]['error'] = str(error)
def get_latest_profile(self) -> Optional[Dict]:
"""Get the most recent workflow profile"""
if not self.history:
logger.debug("No profiles in history")
return None
logger.debug("Returning latest profile")
return self.history[-1]
def get_stats(self) -> Dict:
"""Get all profiling stats including current and historical data"""
stats = {
'current': self.active_profiles,
'latest': self.get_latest_profile(),
'node_averages': dict(self.node_averages),
'workflow_averages': self.workflow_averages,
'history': self.history[-10:] # Return last 10 profiles
}
return stats
def get_archives(self) -> List[Dict]:
"""Get list of archived history files"""
archives = []
archive_dir = os.path.join(self.data_dir, "archives")
os.makedirs(archive_dir, exist_ok=True)
try:
for filename in os.listdir(archive_dir):
if filename.endswith('.json'):
path = os.path.join(archive_dir, filename)
stat = os.stat(path)
archives.append({
'filename': filename,
'size': stat.st_size,
'created': stat.st_ctime,
'modified': stat.st_mtime
})
return sorted(archives, key=lambda x: x['created'], reverse=True)
except Exception as e:
logger.error(f"Failed to get archives: {e}")
return []
def archive_history(self) -> Optional[str]:
"""Archive current history to a file"""
if not self.history:
logger.warning("No history to archive")
return None
archive_dir = os.path.join(self.data_dir, "archives")
os.makedirs(archive_dir, exist_ok=True)
try:
timestamp = int(time.time())
filename = f"profiling_history_{timestamp}.json"
path = os.path.join(archive_dir, filename)
with open(path, 'w') as f:
json.dump(self.history, f, indent=2)
# Clear current history
self.history = []
self._save_history() # Save empty history to current file
logger.debug(f"Created archive: {filename}")
return filename
except Exception as e:
logger.error(f"Failed to create archive: {e}")
return None
def load_archive(self, filename: str) -> bool:
"""Load history from an archive file and delete it after loading"""
path = os.path.join(self.data_dir, "archives", filename)
if not os.path.exists(path):
logger.error(f"Archive not found: {filename}")
return False
try:
# Auto-archive current history if it exists
if self.history:
self.archive_history()
logger.debug("Auto-archived current history before loading new archive")
# Load the archive
with open(path, 'r') as f:
archived_history = json.load(f)
if not isinstance(archived_history, list):
logger.error(f"Invalid archive format: {filename}")
return False
# Update current history
self.history = archived_history
self._save_history() # Update current history file
# Delete the archive file since it's now loaded
os.remove(path)
logger.debug(f"Loaded and deleted archive: {filename}")
return True
except Exception as e:
logger.error(f"Failed to load archive: {e}")
return False
def delete_archive(self, filename: str) -> bool:
"""Delete an archived history file"""
path = os.path.join(self.data_dir, "archives", filename)
if not os.path.exists(path):
logger.error(f"Archive not found: {filename}")
return False
try:
os.remove(path)
logger.debug(f"Deleted archive: {filename}")
return True
except Exception as e:
logger.error(f"Failed to delete archive: {e}")
return False
def _get_tensor_sizes(self, data):
"""Get sizes of tensors in the data"""
if data is None:
return {}
sizes = {}
if isinstance(data, list):
for i, value in enumerate(data):
if isinstance(value, torch.Tensor):
sizes[f"output_{i}"] = list(value.shape)
elif isinstance(value, (list, tuple)):
sizes[f"output_{i}"] = [len(value)]
return sizes
+1
View File
@@ -0,0 +1 @@
psutil>=5.9.0
+48
View File
@@ -0,0 +1,48 @@
"""Server routes for the profiler extension"""
import json
from aiohttp import web
from server import PromptServer
from .profiler_core import ProfilerManager
@PromptServer.instance.routes.get('/profilerx/stats')
async def get_stats(request):
"""Get the current profiling stats"""
profiler = ProfilerManager.get_instance()
stats = profiler.get_stats()
return web.json_response(stats if stats else {})
@PromptServer.instance.routes.get('/profilerx/archives')
async def get_archives(request):
"""Get list of archived history files"""
profiler = ProfilerManager.get_instance()
archives = profiler.get_archives()
return web.json_response(archives)
@PromptServer.instance.routes.post('/profilerx/archive')
async def create_archive(request):
"""Create a new archive of current history"""
profiler = ProfilerManager.get_instance()
archive_name = profiler.archive_history()
if archive_name:
return web.json_response({"success": True, "archive": archive_name})
return web.json_response({"success": False, "error": "Failed to create archive"}, status=500)
@PromptServer.instance.routes.post('/profilerx/archive/{filename}/load')
async def load_archive(request):
"""Load an archived history file"""
filename = request.match_info['filename']
profiler = ProfilerManager.get_instance()
success = profiler.load_archive(filename)
if success:
return web.json_response({"success": True})
return web.json_response({"success": False, "error": f"Failed to load archive: {filename}"}, status=400)
@PromptServer.instance.routes.delete('/profilerx/archive/{filename}')
async def delete_archive(request):
"""Delete an archived history file"""
filename = request.match_info['filename']
profiler = ProfilerManager.get_instance()
success = profiler.delete_archive(filename)
if success:
return web.json_response({"success": True})
return web.json_response({"success": False, "error": f"Failed to delete archive: {filename}"}, status=400)
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "es2020",
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"baseUrl": "./web",
"rootDir": "./web",
"outDir": "./web",
"preserveSymlinks": true,
"allowJs": true,
"declaration": true,
"strict": true,
"moduleResolution": "Bundler",
"skipLibCheck": true,
"isolatedModules": true,
"noEmit": false
},
"include": [
"web/**/*.ts"
],
"exclude": [
"node_modules",
"**/*.d.ts"
]
}
+30
View File
@@ -0,0 +1,30 @@
declare module "../../scripts/app.js" {
interface Node {
onDrawBackground?(ctx: CanvasRenderingContext2D): void;
widgets_start_y: number;
setSize(size: [number, number]): void;
addWidget(type: string, name: string, value: string, options: {
getValue(): string;
setValue(value: string): void;
content: HTMLElement;
}): void;
}
interface NodeDef {
name: string;
category: string;
output: number;
input: number;
onNodeCreated?(node: Node): void;
}
interface App {
registerExtension(extension: {
name: string;
setup(): Promise<void>;
}): void;
registerNodeDef(type: string, def: NodeDef): void;
}
export const app: App;
}
+28
View File
@@ -0,0 +1,28 @@
declare const app: {
registerExtension(extension: {
name: string;
setup(): Promise<void>;
}): void;
};
declare const api: {
fetchApi(path: string): Promise<Response>;
};
interface ComfyNode {
id: number;
type: string;
pos: [number, number];
size: [number, number];
flags: Record<string, boolean>;
properties: Record<string, any>;
widgets_values: any[];
}
interface ComfyWorkflow {
nodes: ComfyNode[];
links: any[];
groups: any[];
config: Record<string, any>;
extra: Record<string, any>;
}
+2
View File
@@ -0,0 +1,2 @@
import { NodeProfile, WorkflowProfile } from './types.js';
export type { NodeProfile, WorkflowProfile };
+57
View File
@@ -0,0 +1,57 @@
/// <reference path="./comfyui.d.ts" />
import { debug } from './utils/debug.js';
import { app } from './utils/api.js';
import { createPerformanceMonitor } from './ui/monitor.js';
import { updateTabContent } from './utils/tabs.js';
// Register the extension
app.registerExtension({
name: "ComfyUI-ProfilerX",
async setup() {
debug("Setting up ProfilerX extension");
// Create and add performance monitor to menu
const menuContainer = document.querySelector('.comfyui-menu-right .flex.gap-2.mx-2');
if (!menuContainer) {
debug("Error: Could not find menu container");
return;
}
debug("Found menu container");
// Create a new button group for our stats
let statsContainer = menuContainer.querySelector('.profilerx-stats-group');
if (!statsContainer) {
debug("Creating new stats container");
statsContainer = document.createElement('div');
statsContainer.className = 'comfyui-button-group profilerx-stats-group';
// Insert after the first button group (usually rgthree)
const firstGroup = menuContainer.querySelector('.comfyui-button-group');
if (firstGroup) {
debug("Adding after first button group");
firstGroup.after(statsContainer);
}
else {
debug("No existing groups, prepending to menu");
menuContainer.prepend(statsContainer);
}
}
// Create our monitor
debug("Creating monitor UI");
const { container } = createPerformanceMonitor();
statsContainer.appendChild(container);
debug("Monitor UI added to DOM");
// Listen for workflow completion through ComfyUI's event system
app.addEventListener("executed", () => {
debug("Workflow executed, updating stats");
const activeTab = document.querySelector('.profilerx-tab.active');
if (activeTab?.dataset.tabId) {
updateTabContent(activeTab.dataset.tabId);
}
});
// Listen for history loaded event (used by archive loading)
document.addEventListener('profiler:historyLoaded', () => {
debug("History loaded, updating stats");
const activeTab = document.querySelector('.profilerx-tab.active');
if (activeTab?.dataset.tabId) {
updateTabContent(activeTab.dataset.tabId);
}
});
},
});
+71
View File
@@ -0,0 +1,71 @@
/// <reference path="./comfyui.d.ts" />
import { NodeProfile, WorkflowProfile } from './types.js';
import { debug } from './utils/debug.js';
import { app } from './utils/api.js';
import { createPerformanceMonitor } from './ui/monitor.js';
import { updateTabContent } from './utils/tabs.js';
// Register the extension
app.registerExtension({
name: "ComfyUI-ProfilerX",
async setup() {
debug("Setting up ProfilerX extension");
// Create and add performance monitor to menu
const menuContainer = document.querySelector('.comfyui-menu-right .flex.gap-2.mx-2');
if (!menuContainer) {
debug("Error: Could not find menu container");
return;
}
debug("Found menu container");
// Create a new button group for our stats
let statsContainer = menuContainer.querySelector('.profilerx-stats-group') as HTMLElement;
if (!statsContainer) {
debug("Creating new stats container");
statsContainer = document.createElement('div');
statsContainer.className = 'comfyui-button-group profilerx-stats-group';
// Insert after the first button group (usually rgthree)
const firstGroup = menuContainer.querySelector('.comfyui-button-group');
if (firstGroup) {
debug("Adding after first button group");
firstGroup.after(statsContainer);
} else {
debug("No existing groups, prepending to menu");
menuContainer.prepend(statsContainer);
}
}
// Create our monitor
debug("Creating monitor UI");
const { container } = createPerformanceMonitor();
statsContainer.appendChild(container);
debug("Monitor UI added to DOM");
// Listen for workflow completion through ComfyUI's event system
app.addEventListener("executed", () => {
debug("Workflow executed, updating stats");
const activeTab = document.querySelector('.profilerx-tab.active') as HTMLElement;
if (activeTab?.dataset.tabId) {
updateTabContent(activeTab.dataset.tabId);
}
});
// Listen for history loaded event (used by archive loading)
document.addEventListener('profiler:historyLoaded', () => {
debug("History loaded, updating stats");
const activeTab = document.querySelector('.profilerx-tab.active') as HTMLElement;
if (activeTab?.dataset.tabId) {
updateTabContent(activeTab.dataset.tabId);
}
});
},
});
// Export types for other modules
export type {
NodeProfile,
WorkflowProfile
};
+105
View File
@@ -0,0 +1,105 @@
// ComfyUI app module
declare module "../../scripts/app.js" {
interface Node {
onDrawBackground?(ctx: CanvasRenderingContext2D): void;
widgets_start_y: number;
setSize(size: [number, number]): void;
addWidget(type: string, name: string, value: string, options: {
getValue(): string;
setValue(value: string): void;
content: HTMLElement;
}): void;
}
interface NodeDef {
name: string;
category: string;
output: number;
input: number;
onNodeCreated?(node: Node): void;
}
interface App {
registerExtension(extension: {
name: string;
setup(): Promise<void>;
}): void;
registerNodeDef(type: string, def: NodeDef): void;
}
export const app: App;
}
// Our internal modules
declare module "./utils" {
export function createStyleSheet(id: string): HTMLStyleElement;
export function formatBytes(bytes: number, decimals?: number): string;
export function formatDuration(ms: number): string;
}
declare module "./types" {
export interface NodeProfile {
nodeId: string;
nodeType: string;
startTime: number;
endTime: number;
vramBefore: number;
vramAfter: number;
ramBefore: number;
ramAfter: number;
inputSizes: Record<string, number>;
outputSizes: Record<string, number>;
cacheHit: boolean;
error?: string;
}
export interface WorkflowProfile {
promptId: string;
startTime: number;
endTime: number;
nodes: Record<string, NodeProfile>;
executionOrder: string[];
totalVramPeak: number;
totalRamPeak: number;
cacheHits: number;
cacheMisses: number;
}
export interface WorkflowSummary {
totalTime: number;
nodeCount: number;
avgNodeTime: number;
maxNodeTime: number;
cacheHitRatio: number;
peakVram: number;
peakRam: number;
}
export interface ChartData {
labels: string[];
datasets: {
label: string;
data: number[];
borderColor?: string;
backgroundColor?: string;
}[];
}
export interface ProfilerSettings {
showMemoryChart: boolean;
showTimeChart: boolean;
showCacheStats: boolean;
refreshRate: number;
maxDataPoints: number;
chartHeight: number;
chartWidth: number;
}
export interface ProfilerUIComponent {
container: HTMLElement;
settings: ProfilerSettings;
render(): void;
update(data: WorkflowProfile): void;
destroy(): void;
}
}
+41
View File
@@ -0,0 +1,41 @@
export interface NodeProfile {
nodeType: string;
startTime: number;
endTime: number;
vramBefore: number;
vramAfter: number;
ramBefore: number;
ramAfter: number;
cacheHit: boolean;
}
export interface WorkflowProfile {
startTime: number;
endTime: number;
nodes: Record<string, NodeProfile>;
totalVramPeak: number;
totalRamPeak: number;
cacheHits: number;
cacheMisses: number;
}
export interface ProfilerStats {
latest: WorkflowProfile;
history: WorkflowProfile[];
node_averages: Record<string, {
total_time: number;
vram_usage: number;
ram_usage: number;
count: number;
cache_hits: number;
}>;
workflow_averages: {
total_time: number;
vram_peak: number;
ram_peak: number;
count: number;
};
}
export interface Archive {
filename: string;
created: number;
size: number;
}
+2
View File
@@ -0,0 +1,2 @@
// Type definitions for ProfilerX
export {};
+46
View File
@@ -0,0 +1,46 @@
// Type definitions for ProfilerX
export interface NodeProfile {
nodeType: string;
startTime: number;
endTime: number;
vramBefore: number;
vramAfter: number;
ramBefore: number;
ramAfter: number;
cacheHit: boolean;
}
export interface WorkflowProfile {
startTime: number;
endTime: number;
nodes: Record<string, NodeProfile>;
totalVramPeak: number;
totalRamPeak: number;
cacheHits: number;
cacheMisses: number;
}
export interface ProfilerStats {
latest: WorkflowProfile;
history: WorkflowProfile[];
node_averages: Record<string, {
total_time: number;
vram_usage: number;
ram_usage: number;
count: number;
cache_hits: number;
}>;
workflow_averages: {
total_time: number;
vram_peak: number;
ram_peak: number;
count: number;
};
}
export interface Archive {
filename: string;
created: number;
size: number;
}
+7
View File
@@ -0,0 +1,7 @@
export declare function createPerformanceMonitor(): {
container: HTMLDivElement;
elements: {
button: HTMLButtonElement;
popup: HTMLDivElement;
};
};
+86
View File
@@ -0,0 +1,86 @@
// Performance monitor UI component
import { debug } from '../utils/debug.js';
import { updateMenuStats } from '../utils/tabs.js';
export function createPerformanceMonitor() {
debug("Creating performance monitor UI");
const container = document.createElement('div');
container.className = 'comfyui-button-wrapper';
container.style.position = 'relative';
const button = document.createElement('button');
button.className = 'comfyui-button primary popup-closed';
button.title = 'Profiling Stats';
button.setAttribute('aria-label', 'Profiling Stats');
// Create icon
const icon = document.createElement('i');
icon.className = 'mdi mdi-chart-line';
button.appendChild(icon);
// Add empty text node (like other buttons)
button.appendChild(document.createTextNode(''));
container.appendChild(button);
// Create detailed stats popup with proper positioning
const popup = document.createElement('div');
popup.className = 'comfyui-popup profilerx-stats-popup left';
popup.style.cssText = `
display: none;
position: fixed;
background: var(--comfy-input-bg);
color: var(--descrip-text);
padding: 8px;
border-radius: 4px;
z-index: 9999;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
border: 1px solid var(--border-color);
min-width: 300px;
font-size: 12px;
`;
container.appendChild(popup);
// Toggle popup on click
button.addEventListener('click', (e) => {
debug("Button clicked!");
e.preventDefault();
e.stopPropagation();
const isVisible = popup.style.display !== 'none';
debug("Current popup visibility:", popup.style.display, "isVisible:", isVisible);
if (!isVisible) {
// Position the popup relative to the button
const rect = button.getBoundingClientRect();
popup.style.setProperty('--left', `${rect.left}px`);
popup.style.setProperty('--top', `${rect.bottom}px`);
popup.style.setProperty('--limit', `${rect.bottom}px`);
popup.classList.add('open');
popup.style.display = 'block';
updateMenuStats(popup);
}
else {
popup.classList.remove('open');
popup.style.display = 'none';
}
debug("New popup display:", popup.style.display);
button.classList.toggle('popup-opened', !isVisible);
button.classList.toggle('popup-closed', isVisible);
debug("Button classes after toggle:", button.className);
});
// Close popup when clicking outside
document.addEventListener('click', (e) => {
debug("Document clicked, checking if outside container");
if (!container.contains(e.target)) {
debug("Click was outside container, closing popup");
popup.style.display = 'none';
popup.classList.remove('open');
button.classList.remove('popup-opened');
button.classList.add('popup-closed');
}
else {
debug("Click was inside container");
}
});
return {
container,
elements: {
button,
popup
}
};
}
+97
View File
@@ -0,0 +1,97 @@
// Performance monitor UI component
import { debug } from '../utils/debug.js';
import { createTabbedInterface } from './tabs.js';
import { updateMenuStats } from '../utils/tabs.js';
export function createPerformanceMonitor() {
debug("Creating performance monitor UI");
const container = document.createElement('div');
container.className = 'comfyui-button-wrapper';
container.style.position = 'relative';
const button = document.createElement('button');
button.className = 'comfyui-button primary popup-closed';
button.title = 'Profiling Stats';
button.setAttribute('aria-label', 'Profiling Stats');
// Create icon
const icon = document.createElement('i');
icon.className = 'mdi mdi-chart-line';
button.appendChild(icon);
// Add empty text node (like other buttons)
button.appendChild(document.createTextNode(''));
container.appendChild(button);
// Create detailed stats popup with proper positioning
const popup = document.createElement('div');
popup.className = 'comfyui-popup profilerx-stats-popup left';
popup.style.cssText = `
display: none;
position: fixed;
background: var(--comfy-input-bg);
color: var(--descrip-text);
padding: 8px;
border-radius: 4px;
z-index: 9999;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
border: 1px solid var(--border-color);
min-width: 300px;
font-size: 12px;
`;
container.appendChild(popup);
// Toggle popup on click
button.addEventListener('click', (e) => {
debug("Button clicked!");
e.preventDefault();
e.stopPropagation();
const isVisible = popup.style.display !== 'none';
debug("Current popup visibility:", popup.style.display, "isVisible:", isVisible);
if (!isVisible) {
// Position the popup relative to the button
const rect = button.getBoundingClientRect();
popup.style.setProperty('--left', `${rect.left}px`);
popup.style.setProperty('--top', `${rect.bottom}px`);
popup.style.setProperty('--limit', `${rect.bottom}px`);
popup.classList.add('open');
popup.style.display = 'block';
updateMenuStats(popup);
} else {
popup.classList.remove('open');
popup.style.display = 'none';
}
debug("New popup display:", popup.style.display);
button.classList.toggle('popup-opened', !isVisible);
button.classList.toggle('popup-closed', isVisible);
debug("Button classes after toggle:", button.className);
});
// Close popup when clicking outside
document.addEventListener('click', (e) => {
debug("Document clicked, checking if outside container");
if (!container.contains(e.target as Node)) {
debug("Click was outside container, closing popup");
popup.style.display = 'none';
popup.classList.remove('open');
button.classList.remove('popup-opened');
button.classList.add('popup-closed');
} else {
debug("Click was inside container");
}
});
return {
container,
elements: {
button,
popup
}
};
}
+9
View File
@@ -0,0 +1,9 @@
interface PopupElements {
button: HTMLButtonElement;
popup: HTMLDivElement;
}
export declare function createPopup(): {
container: HTMLDivElement;
elements: PopupElements;
};
export {};
+87
View File
@@ -0,0 +1,87 @@
// Popup UI component for detailed stats display
import { debug } from '../utils/debug.js';
export function createPopup() {
const container = document.createElement('div');
container.className = 'comfyui-button-wrapper';
container.style.position = 'relative';
const button = document.createElement('button');
button.className = 'comfyui-button primary popup-closed';
button.title = 'Profiling Stats';
button.style.cssText = `
background: var(--comfy-input-bg);
&:hover {
background: var(--comfy-input-bg);
filter: brightness(1.2);
}
`;
// Create icon
const icon = document.createElement('i');
icon.className = 'mdi mdi-chart-line';
button.appendChild(icon);
container.appendChild(button);
// Create detailed stats popup with proper positioning
const popup = document.createElement('div');
popup.className = 'comfyui-popup profilerx-stats-popup left';
popup.style.cssText = `
display: none;
position: fixed;
background: var(--comfy-input-bg);
color: var(--descrip-text);
padding: 8px;
border-radius: 4px;
z-index: 9999;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
border: 1px solid var(--border-color);
min-width: 300px;
font-size: 12px;
`;
container.appendChild(popup);
setupPopupEvents(container, button, popup);
return {
container,
elements: { button, popup }
};
}
function setupPopupEvents(container, button, popup) {
// Toggle popup on click
button.addEventListener('click', (e) => {
debug("Button clicked!");
e.preventDefault();
e.stopPropagation();
const isVisible = popup.style.display !== 'none';
debug("Current popup visibility:", popup.style.display, "isVisible:", isVisible);
if (!isVisible) {
// Position the popup relative to the button
const rect = button.getBoundingClientRect();
popup.style.setProperty('--left', `${rect.left}px`);
popup.style.setProperty('--top', `${rect.bottom}px`);
popup.style.setProperty('--limit', `${rect.bottom}px`);
popup.classList.add('open');
popup.style.display = 'block';
}
else {
popup.classList.remove('open');
popup.style.display = 'none';
}
debug("New popup display:", popup.style.display);
button.classList.toggle('popup-opened', !isVisible);
button.classList.toggle('popup-closed', isVisible);
debug("Button classes after toggle:", button.className);
});
// Close popup when clicking outside
document.addEventListener('click', (e) => {
debug("Document clicked, checking if outside container");
if (!container.contains(e.target)) {
debug("Click was outside container, closing popup");
popup.style.display = 'none';
popup.classList.remove('open');
button.classList.remove('popup-opened');
button.classList.add('popup-closed');
}
else {
debug("Click was inside container");
}
});
}
+102
View File
@@ -0,0 +1,102 @@
// Popup UI component for detailed stats display
import { debug } from '../utils/debug.js';
interface PopupElements {
button: HTMLButtonElement;
popup: HTMLDivElement;
}
export function createPopup(): { container: HTMLDivElement, elements: PopupElements } {
const container = document.createElement('div');
container.className = 'comfyui-button-wrapper';
container.style.position = 'relative';
const button = document.createElement('button');
button.className = 'comfyui-button primary popup-closed';
button.title = 'Profiling Stats';
button.style.cssText = `
background: var(--comfy-input-bg);
&:hover {
background: var(--comfy-input-bg);
filter: brightness(1.2);
}
`;
// Create icon
const icon = document.createElement('i');
icon.className = 'mdi mdi-chart-line';
button.appendChild(icon);
container.appendChild(button);
// Create detailed stats popup with proper positioning
const popup = document.createElement('div');
popup.className = 'comfyui-popup profilerx-stats-popup left';
popup.style.cssText = `
display: none;
position: fixed;
background: var(--comfy-input-bg);
color: var(--descrip-text);
padding: 8px;
border-radius: 4px;
z-index: 9999;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
border: 1px solid var(--border-color);
min-width: 300px;
font-size: 12px;
`;
container.appendChild(popup);
setupPopupEvents(container, button, popup);
return {
container,
elements: { button, popup }
};
}
function setupPopupEvents(container: HTMLDivElement, button: HTMLButtonElement, popup: HTMLDivElement) {
// Toggle popup on click
button.addEventListener('click', (e) => {
debug("Button clicked!");
e.preventDefault();
e.stopPropagation();
const isVisible = popup.style.display !== 'none';
debug("Current popup visibility:", popup.style.display, "isVisible:", isVisible);
if (!isVisible) {
// Position the popup relative to the button
const rect = button.getBoundingClientRect();
popup.style.setProperty('--left', `${rect.left}px`);
popup.style.setProperty('--top', `${rect.bottom}px`);
popup.style.setProperty('--limit', `${rect.bottom}px`);
popup.classList.add('open');
popup.style.display = 'block';
} else {
popup.classList.remove('open');
popup.style.display = 'none';
}
debug("New popup display:", popup.style.display);
button.classList.toggle('popup-opened', !isVisible);
button.classList.toggle('popup-closed', isVisible);
debug("Button classes after toggle:", button.className);
});
// Close popup when clicking outside
document.addEventListener('click', (e) => {
debug("Document clicked, checking if outside container");
if (!container.contains(e.target as Node)) {
debug("Click was outside container, closing popup");
popup.style.display = 'none';
popup.classList.remove('open');
button.classList.remove('popup-opened');
button.classList.add('popup-closed');
} else {
debug("Click was inside container");
}
});
}
+2
View File
@@ -0,0 +1,2 @@
export declare function applyTableStyles(table: HTMLTableElement): void;
export declare function makeSortable(table: HTMLTableElement): void;
+190
View File
@@ -0,0 +1,190 @@
// Table sorting and styling utilities
// Add table styling helper
export function applyTableStyles(table) {
table.style.cssText = `
width: 100%;
border-collapse: collapse;
font-size: 12px;
background: var(--comfy-input-bg);
border-radius: 8px;
overflow: hidden;
margin: 12px 0;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
`;
// Style header cells
table.querySelectorAll('thead th').forEach(th => {
const header = th;
header.style.cssText = `
padding: 12px 8px;
text-align: left;
border-bottom: 2px solid var(--border-color);
font-weight: 600;
white-space: nowrap;
color: var(--descrip-text);
background: rgba(0, 0, 0, 0.2);
`;
});
// Style body cells
table.querySelectorAll('tbody td').forEach(td => {
const cell = td;
cell.style.cssText = `
padding: 8px;
border-bottom: 1px solid var(--border-color);
transition: background-color 0.2s;
`;
});
// Add hover effect to rows
table.querySelectorAll('tbody tr').forEach(tr => {
const row = tr;
row.style.transition = 'background-color 0.2s';
row.addEventListener('mouseenter', () => {
row.style.backgroundColor = 'rgba(255, 255, 255, 0.05)';
});
row.addEventListener('mouseleave', () => {
row.style.backgroundColor = '';
});
});
// Add alternating row colors
table.querySelectorAll('tbody tr:nth-child(even)').forEach(tr => {
const row = tr;
row.style.backgroundColor = 'rgba(0, 0, 0, 0.1)';
});
}
// Add utility functions for sorting
export function makeSortable(table) {
const headers = table.querySelectorAll('th');
const tbody = table.querySelector('tbody');
headers.forEach((header, index) => {
const th = header;
if (th.classList.contains('no-sort'))
return;
th.style.cssText = `
cursor: pointer;
user-select: none;
position: relative;
padding: 8px 24px 8px 8px;
text-align: left;
border-bottom: 2px solid var(--border-color);
transition: background-color 0.2s;
font-weight: 600;
color: var(--descrip-text);
`;
// Add sort indicator
const indicator = document.createElement('span');
indicator.className = 'sort-indicator';
indicator.style.cssText = `
position: absolute;
right: 8px;
opacity: 0.5;
`;
indicator.textContent = '⇅';
th.appendChild(indicator);
th.addEventListener('mouseenter', () => {
th.style.backgroundColor = 'rgba(255, 255, 255, 0.05)';
});
th.addEventListener('mouseleave', () => {
if (!th.classList.contains('sort-asc') && !th.classList.contains('sort-desc')) {
th.style.backgroundColor = '';
}
});
th.addEventListener('click', () => {
if (!tbody)
return;
const rows = Array.from(tbody.querySelectorAll('tr'));
const isAscending = th.classList.contains('sort-asc');
// Clear previous sort indicators
headers.forEach(h => {
const header = h;
header.style.backgroundColor = '';
header.classList.remove('sort-asc', 'sort-desc');
const ind = header.querySelector('.sort-indicator');
if (ind)
ind.textContent = '⇅';
});
// Sort rows
rows.sort((a, b) => {
const aCell = a.children[index];
const bCell = b.children[index];
if (!aCell || !bCell)
return 0;
let aValue;
let bValue;
// Handle different column types
if (th.classList.contains('sort-numeric')) {
// Extract numeric value, handling units like GB, MB, s, ms
const aText = aCell.textContent?.trim() || '0';
const bText = bCell.textContent?.trim() || '0';
// Convert to base unit (bytes for memory, ms for time)
const convertToBaseUnit = (text) => {
const num = parseFloat(text.replace(/[^\d.-]/g, '')) || 0;
if (text.includes('GB'))
return num * 1024 * 1024 * 1024;
if (text.includes('MB'))
return num * 1024 * 1024;
if (text.includes('KB'))
return num * 1024;
if (text.includes('s') && !text.includes('ms'))
return num * 1000;
return num;
};
aValue = convertToBaseUnit(aText);
bValue = convertToBaseUnit(bText);
}
else if (th.classList.contains('sort-time')) {
// Special handling for time values
const parseTime = (cell) => {
// First try to get timestamp from data attribute
const timestamp = cell.getAttribute('data-timestamp');
if (timestamp) {
return parseInt(timestamp);
}
// Fallback to parsing time string
const timeStr = cell.textContent?.trim() || '';
const timeParts = timeStr.split(':');
if (timeParts.length === 3) {
const [hours, minutes, seconds] = timeParts.map(Number);
return hours * 3600000 + minutes * 60000 + seconds * 1000;
}
return 0;
};
aValue = parseTime(aCell);
bValue = parseTime(bCell);
}
else {
// Text sorting - handle nested content
const getMainContent = (cell) => {
// For cells with nested divs (like Node Type column), get only the first div's content
const firstDiv = cell.querySelector('div');
if (firstDiv) {
return firstDiv.textContent || '';
}
return cell.textContent || '';
};
aValue = getMainContent(aCell).trim().toLowerCase();
bValue = getMainContent(bCell).trim().toLowerCase();
}
if (typeof aValue === 'string' && typeof bValue === 'string') {
return isAscending ?
aValue.localeCompare(bValue) :
bValue.localeCompare(aValue);
}
else {
const numA = Number(aValue);
const numB = Number(bValue);
return isAscending ? numA - numB : numB - numA;
}
});
// Update sort indicator
th.classList.toggle('sort-asc', !isAscending);
th.classList.toggle('sort-desc', isAscending);
th.style.backgroundColor = 'rgba(255, 255, 255, 0.05)';
const indicator = th.querySelector('.sort-indicator');
if (indicator) {
indicator.textContent = isAscending ? '⇓' : '⇑';
indicator.style.opacity = '1';
}
// Reorder rows
rows.forEach(row => tbody.appendChild(row));
});
});
}
+208
View File
@@ -0,0 +1,208 @@
// Table sorting and styling utilities
// Add table styling helper
export function applyTableStyles(table: HTMLTableElement) {
table.style.cssText = `
width: 100%;
border-collapse: collapse;
font-size: 12px;
background: var(--comfy-input-bg);
border-radius: 8px;
overflow: hidden;
margin: 12px 0;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
`;
// Style header cells
table.querySelectorAll('thead th').forEach(th => {
const header = th as HTMLTableHeaderCellElement;
header.style.cssText = `
padding: 12px 8px;
text-align: left;
border-bottom: 2px solid var(--border-color);
font-weight: 600;
white-space: nowrap;
color: var(--descrip-text);
background: rgba(0, 0, 0, 0.2);
`;
});
// Style body cells
table.querySelectorAll('tbody td').forEach(td => {
const cell = td as HTMLTableCellElement;
cell.style.cssText = `
padding: 8px;
border-bottom: 1px solid var(--border-color);
transition: background-color 0.2s;
`;
});
// Add hover effect to rows
table.querySelectorAll('tbody tr').forEach(tr => {
const row = tr as HTMLTableRowElement;
row.style.transition = 'background-color 0.2s';
row.addEventListener('mouseenter', () => {
row.style.backgroundColor = 'rgba(255, 255, 255, 0.05)';
});
row.addEventListener('mouseleave', () => {
row.style.backgroundColor = '';
});
});
// Add alternating row colors
table.querySelectorAll('tbody tr:nth-child(even)').forEach(tr => {
const row = tr as HTMLTableRowElement;
row.style.backgroundColor = 'rgba(0, 0, 0, 0.1)';
});
}
// Add utility functions for sorting
export function makeSortable(table: HTMLTableElement) {
const headers = table.querySelectorAll('th');
const tbody = table.querySelector('tbody') as HTMLTableSectionElement;
headers.forEach((header, index) => {
const th = header as HTMLTableHeaderCellElement;
if (th.classList.contains('no-sort')) return;
th.style.cssText = `
cursor: pointer;
user-select: none;
position: relative;
padding: 8px 24px 8px 8px;
text-align: left;
border-bottom: 2px solid var(--border-color);
transition: background-color 0.2s;
font-weight: 600;
color: var(--descrip-text);
`;
// Add sort indicator
const indicator = document.createElement('span');
indicator.className = 'sort-indicator';
indicator.style.cssText = `
position: absolute;
right: 8px;
opacity: 0.5;
`;
indicator.textContent = '⇅';
th.appendChild(indicator);
th.addEventListener('mouseenter', () => {
th.style.backgroundColor = 'rgba(255, 255, 255, 0.05)';
});
th.addEventListener('mouseleave', () => {
if (!th.classList.contains('sort-asc') && !th.classList.contains('sort-desc')) {
th.style.backgroundColor = '';
}
});
th.addEventListener('click', () => {
if (!tbody) return;
const rows = Array.from(tbody.querySelectorAll('tr'));
const isAscending = th.classList.contains('sort-asc');
// Clear previous sort indicators
headers.forEach(h => {
const header = h as HTMLTableHeaderCellElement;
header.style.backgroundColor = '';
header.classList.remove('sort-asc', 'sort-desc');
const ind = header.querySelector('.sort-indicator');
if (ind) ind.textContent = '⇅';
});
// Sort rows
rows.sort((a, b) => {
const aCell = a.children[index] as HTMLTableCellElement;
const bCell = b.children[index] as HTMLTableCellElement;
if (!aCell || !bCell) return 0;
let aValue: string | number;
let bValue: string | number;
// Handle different column types
if (th.classList.contains('sort-numeric')) {
// Extract numeric value, handling units like GB, MB, s, ms
const aText = aCell.textContent?.trim() || '0';
const bText = bCell.textContent?.trim() || '0';
// Convert to base unit (bytes for memory, ms for time)
const convertToBaseUnit = (text: string): number => {
const num = parseFloat(text.replace(/[^\d.-]/g, '')) || 0;
if (text.includes('GB')) return num * 1024 * 1024 * 1024;
if (text.includes('MB')) return num * 1024 * 1024;
if (text.includes('KB')) return num * 1024;
if (text.includes('s') && !text.includes('ms')) return num * 1000;
return num;
};
aValue = convertToBaseUnit(aText);
bValue = convertToBaseUnit(bText);
} else if (th.classList.contains('sort-time')) {
// Special handling for time values
const parseTime = (cell: HTMLTableCellElement): number => {
// First try to get timestamp from data attribute
const timestamp = cell.getAttribute('data-timestamp');
if (timestamp) {
return parseInt(timestamp);
}
// Fallback to parsing time string
const timeStr = cell.textContent?.trim() || '';
const timeParts = timeStr.split(':');
if (timeParts.length === 3) {
const [hours, minutes, seconds] = timeParts.map(Number);
return hours * 3600000 + minutes * 60000 + seconds * 1000;
}
return 0;
};
aValue = parseTime(aCell);
bValue = parseTime(bCell);
} else {
// Text sorting - handle nested content
const getMainContent = (cell: HTMLTableCellElement): string => {
// For cells with nested divs (like Node Type column), get only the first div's content
const firstDiv = cell.querySelector('div');
if (firstDiv) {
return firstDiv.textContent || '';
}
return cell.textContent || '';
};
aValue = getMainContent(aCell).trim().toLowerCase();
bValue = getMainContent(bCell).trim().toLowerCase();
}
if (typeof aValue === 'string' && typeof bValue === 'string') {
return isAscending ?
aValue.localeCompare(bValue) :
bValue.localeCompare(aValue);
} else {
const numA = Number(aValue);
const numB = Number(bValue);
return isAscending ? numA - numB : numB - numA;
}
});
// Update sort indicator
th.classList.toggle('sort-asc', !isAscending);
th.classList.toggle('sort-desc', isAscending);
th.style.backgroundColor = 'rgba(255, 255, 255, 0.05)';
const indicator = th.querySelector('.sort-indicator') as HTMLSpanElement;
if (indicator) {
indicator.textContent = isAscending ? '⇓' : '⇑';
indicator.style.opacity = '1';
}
// Reorder rows
rows.forEach(row => tbody.appendChild(row));
});
});
}
+2
View File
@@ -0,0 +1,2 @@
export declare function createTabbedInterface(popupElement: HTMLElement): HTMLDivElement;
export declare function switchTab(tabId: string): void;
+57
View File
@@ -0,0 +1,57 @@
// Tab interface component
import { updateTabContent } from '../utils/tabs.js';
export function createTabbedInterface(popupElement) {
const tabContainer = document.createElement('div');
tabContainer.className = 'profilerx-tabs';
tabContainer.style.cssText = `
display: flex;
border-bottom: 1px solid var(--border-color);
margin-bottom: 8px;
`;
const tabs = [
{ id: 'latest', label: 'Latest Run', icon: '⚡' },
{ id: 'history', label: 'Historical Trends', icon: '📈' },
{ id: 'nodes', label: 'Node Analytics', icon: '🔍' },
{ id: 'settings', label: 'Settings', icon: '⚙️' }
];
tabs.forEach(tab => {
const tabButton = document.createElement('button');
tabButton.className = 'profilerx-tab';
tabButton.dataset.tabId = tab.id;
tabButton.innerHTML = `${tab.icon} ${tab.label}`;
tabButton.style.cssText = `
padding: 8px 12px;
background: none;
border: none;
color: var(--descrip-text);
cursor: pointer;
opacity: 0.7;
transition: opacity 0.2s;
`;
tabButton.addEventListener('mouseenter', () => tabButton.style.opacity = '1');
tabButton.addEventListener('mouseleave', () => {
if (!tabButton.classList.contains('active')) {
tabButton.style.opacity = '0.7';
}
});
tabButton.addEventListener('click', () => switchTab(tab.id));
tabContainer.appendChild(tabButton);
});
return tabContainer;
}
export function switchTab(tabId) {
const tabs = document.querySelectorAll('.profilerx-tab');
const activeTab = document.querySelector(`.profilerx-tab[data-tab-id="${tabId}"]`);
tabs.forEach(tab => {
const tabElement = tab;
tabElement.classList.remove('active');
tabElement.style.opacity = '0.7';
tabElement.style.borderBottom = 'none';
});
if (activeTab) {
activeTab.classList.add('active');
activeTab.style.opacity = '1';
activeTab.style.borderBottom = '2px solid var(--descrip-text)';
}
updateTabContent(tabId);
}
+65
View File
@@ -0,0 +1,65 @@
// Tab interface component
import { updateTabContent } from '../utils/tabs.js';
export function createTabbedInterface(popupElement: HTMLElement) {
const tabContainer = document.createElement('div');
tabContainer.className = 'profilerx-tabs';
tabContainer.style.cssText = `
display: flex;
border-bottom: 1px solid var(--border-color);
margin-bottom: 8px;
`;
const tabs = [
{ id: 'latest', label: 'Latest Run', icon: '⚡' },
{ id: 'history', label: 'Historical Trends', icon: '📈' },
{ id: 'nodes', label: 'Node Analytics', icon: '🔍' },
{ id: 'settings', label: 'Settings', icon: '⚙️' }
];
tabs.forEach(tab => {
const tabButton = document.createElement('button');
tabButton.className = 'profilerx-tab';
tabButton.dataset.tabId = tab.id;
tabButton.innerHTML = `${tab.icon} ${tab.label}`;
tabButton.style.cssText = `
padding: 8px 12px;
background: none;
border: none;
color: var(--descrip-text);
cursor: pointer;
opacity: 0.7;
transition: opacity 0.2s;
`;
tabButton.addEventListener('mouseenter', () => tabButton.style.opacity = '1');
tabButton.addEventListener('mouseleave', () => {
if (!tabButton.classList.contains('active')) {
tabButton.style.opacity = '0.7';
}
});
tabButton.addEventListener('click', () => switchTab(tab.id));
tabContainer.appendChild(tabButton);
});
return tabContainer;
}
export function switchTab(tabId: string) {
const tabs = document.querySelectorAll('.profilerx-tab');
const activeTab = document.querySelector(`.profilerx-tab[data-tab-id="${tabId}"]`) as HTMLElement;
tabs.forEach(tab => {
const tabElement = tab as HTMLElement;
tabElement.classList.remove('active');
tabElement.style.opacity = '0.7';
tabElement.style.borderBottom = 'none';
});
if (activeTab) {
activeTab.classList.add('active');
activeTab.style.opacity = '1';
activeTab.style.borderBottom = '2px solid var(--descrip-text)';
}
updateTabContent(tabId);
}
+10
View File
@@ -0,0 +1,10 @@
export interface TimeRangeOption {
value: string;
label: string;
}
export interface TimeRangeSelector {
container: HTMLElement;
select: HTMLSelectElement;
}
export declare function createTimeRangeSelector(): TimeRangeSelector;
export declare function filterHistoryByTimeRange(history: any[], timeRange: string): any[];
+49
View File
@@ -0,0 +1,49 @@
// Time range selector component
export function createTimeRangeSelector() {
const container = document.createElement('div');
container.style.cssText = `
margin: 8px 0;
display: flex;
align-items: center;
gap: 8px;
`;
const label = document.createElement('label');
label.textContent = 'Show data from last: ';
const select = document.createElement('select');
select.style.cssText = `
background: var(--comfy-input-bg);
color: var(--descrip-text);
border: 1px solid var(--border-color);
padding: 4px;
border-radius: 4px;
`;
const options = [
{ value: '1h', label: '1 hour' },
{ value: '6h', label: '6 hours' },
{ value: '24h', label: '24 hours' },
{ value: '7d', label: '7 days' },
{ value: 'all', label: 'All time' }
];
options.forEach(opt => {
const option = document.createElement('option');
option.value = opt.value;
option.textContent = opt.label;
select.appendChild(option);
});
container.appendChild(label);
container.appendChild(select);
return { container, select };
}
export function filterHistoryByTimeRange(history, timeRange) {
if (timeRange === 'all')
return history;
const now = Date.now();
const ranges = {
'1h': 60 * 60 * 1000,
'6h': 6 * 60 * 60 * 1000,
'24h': 24 * 60 * 60 * 1000,
'7d': 7 * 24 * 60 * 60 * 1000
};
const cutoff = now - (ranges[timeRange] || 0);
return history.filter(item => item.startTime > cutoff);
}
+68
View File
@@ -0,0 +1,68 @@
// Time range selector component
export interface TimeRangeOption {
value: string;
label: string;
}
export interface TimeRangeSelector {
container: HTMLElement;
select: HTMLSelectElement;
}
export function createTimeRangeSelector(): TimeRangeSelector {
const container = document.createElement('div');
container.style.cssText = `
margin: 8px 0;
display: flex;
align-items: center;
gap: 8px;
`;
const label = document.createElement('label');
label.textContent = 'Show data from last: ';
const select = document.createElement('select');
select.style.cssText = `
background: var(--comfy-input-bg);
color: var(--descrip-text);
border: 1px solid var(--border-color);
padding: 4px;
border-radius: 4px;
`;
const options: TimeRangeOption[] = [
{ value: '1h', label: '1 hour' },
{ value: '6h', label: '6 hours' },
{ value: '24h', label: '24 hours' },
{ value: '7d', label: '7 days' },
{ value: 'all', label: 'All time' }
];
options.forEach(opt => {
const option = document.createElement('option');
option.value = opt.value;
option.textContent = opt.label;
select.appendChild(option);
});
container.appendChild(label);
container.appendChild(select);
return { container, select };
}
export function filterHistoryByTimeRange(history: any[], timeRange: string): any[] {
if (timeRange === 'all') return history;
const now = Date.now();
const ranges = {
'1h': 60 * 60 * 1000,
'6h': 6 * 60 * 60 * 1000,
'24h': 24 * 60 * 60 * 1000,
'7d': 7 * 24 * 60 * 60 * 1000
};
const cutoff = now - (ranges[timeRange as keyof typeof ranges] || 0);
return history.filter(item => item.startTime > cutoff);
}
+3
View File
@@ -0,0 +1,3 @@
export declare const app: any;
export declare const api: any;
export declare function fetchProfilerStats(): Promise<any>;
+8
View File
@@ -0,0 +1,8 @@
// ComfyUI API access utilities
// Access ComfyUI's app and api instances
export const app = window.comfyAPI?.app?.app;
export const api = window.comfyAPI?.api?.api;
// Fetch profiler stats from the API
export async function fetchProfilerStats() {
return await api.fetchApi('/profilerx/stats').then((r) => r.json());
}
+10
View File
@@ -0,0 +1,10 @@
// ComfyUI API access utilities
// Access ComfyUI's app and api instances
export const app = (window as any).comfyAPI?.app?.app;
export const api = (window as any).comfyAPI?.api?.api;
// Fetch profiler stats from the API
export async function fetchProfilerStats() {
return await api.fetchApi('/profilerx/stats').then((r: Response) => r.json());
}
+1
View File
@@ -0,0 +1 @@
export declare function debug(...args: any[]): void;
+4
View File
@@ -0,0 +1,4 @@
// Debug logging utilities
export function debug(...args) {
// console.log("[ProfilerX]", ...args);
}
+6
View File
@@ -0,0 +1,6 @@
// Debug logging utilities
export function debug(...args: any[]) {
// console.log("[ProfilerX]", ...args);
return;
}
+2
View File
@@ -0,0 +1,2 @@
export declare function updateTabContent(tabId: string): Promise<void>;
export declare function updateMenuStats(popupElement: HTMLElement): void;
+82
View File
@@ -0,0 +1,82 @@
// Tab content management utilities
import { api } from './api.js';
import { debug } from './debug.js';
import { createTabbedInterface } from '../ui/tabs.js';
import { showLatestRun } from '../views/latest.js';
import { showHistoricalTrends } from '../views/history.js';
import { showNodeAnalytics } from '../views/analytics.js';
import { showSettings } from '../views/settings.js';
export async function updateTabContent(tabId) {
const contentElement = document.querySelector('.profilerx-content');
if (!contentElement)
return;
try {
// Try up to 3 times with increasing delays
let stats = null;
for (let i = 0; i < 3; i++) {
try {
const response = await api.fetchApi('/profilerx/stats');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
stats = await response.json();
break;
}
catch (error) {
debug(`Attempt ${i + 1} failed to fetch stats:`, error);
if (i < 2) {
await new Promise(resolve => setTimeout(resolve, 100 * Math.pow(2, i)));
continue;
}
throw error;
}
}
if (!stats) {
throw new Error('Failed to fetch stats after retries');
}
switch (tabId) {
case 'latest':
showLatestRun(contentElement, stats);
break;
case 'history':
showHistoricalTrends(contentElement, stats);
break;
case 'nodes':
showNodeAnalytics(contentElement, stats);
break;
case 'settings':
showSettings(contentElement);
break;
}
}
catch (error) {
debug('Error updating tab content:', error);
contentElement.innerHTML = '<div style="padding: 20px; text-align: center;">Failed to load content. Please try refreshing.</div>';
}
}
export function updateMenuStats(popupElement) {
debug("Updating menu stats with profile");
if (!popupElement) {
debug("Warning: No popup element provided");
return;
}
// Only set up the tabs if they don't exist
if (!popupElement.querySelector('.profilerx-tabs')) {
// Add tabs
const tabContainer = createTabbedInterface(popupElement);
popupElement.appendChild(tabContainer);
// Add content container
const contentContainer = document.createElement('div');
contentContainer.className = 'profilerx-content';
popupElement.appendChild(contentContainer);
// Show latest run by default
updateTabContent('latest');
}
else {
// Just update the current tab's content
const activeTab = popupElement.querySelector('.profilerx-tab.active');
if (activeTab?.dataset.tabId) {
updateTabContent(activeTab.dataset.tabId);
}
}
}
+93
View File
@@ -0,0 +1,93 @@
// Tab content management utilities
import { api } from './api.js';
import { debug } from './debug.js';
import { createTabbedInterface } from '../ui/tabs.js';
import { showLatestRun } from '../views/latest.js';
import { showHistoricalTrends } from '../views/history.js';
import { showNodeAnalytics } from '../views/analytics.js';
import { showSettings } from '../views/settings.js';
export async function updateTabContent(tabId: string) {
interface ProfilerStats {
latest: any;
history: any[];
node_averages: Record<string, any>;
workflow_averages: any;
}
const contentElement = document.querySelector('.profilerx-content') as HTMLElement;
if (!contentElement) return;
try {
// Try up to 3 times with increasing delays
let stats: ProfilerStats | null = null;
for (let i = 0; i < 3; i++) {
try {
const response = await api.fetchApi('/profilerx/stats');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
stats = await response.json();
break;
} catch (error) {
debug(`Attempt ${i + 1} failed to fetch stats:`, error);
if (i < 2) {
await new Promise(resolve => setTimeout(resolve, 100 * Math.pow(2, i)));
continue;
}
throw error;
}
}
if (!stats) {
throw new Error('Failed to fetch stats after retries');
}
switch (tabId) {
case 'latest':
showLatestRun(contentElement, stats);
break;
case 'history':
showHistoricalTrends(contentElement, stats);
break;
case 'nodes':
showNodeAnalytics(contentElement, stats);
break;
case 'settings':
showSettings(contentElement);
break;
}
} catch (error) {
debug('Error updating tab content:', error);
contentElement.innerHTML = '<div style="padding: 20px; text-align: center;">Failed to load content. Please try refreshing.</div>';
}
}
export function updateMenuStats(popupElement: HTMLElement) {
debug("Updating menu stats with profile");
if (!popupElement) {
debug("Warning: No popup element provided");
return;
}
// Only set up the tabs if they don't exist
if (!popupElement.querySelector('.profilerx-tabs')) {
// Add tabs
const tabContainer = createTabbedInterface(popupElement);
popupElement.appendChild(tabContainer);
// Add content container
const contentContainer = document.createElement('div');
contentContainer.className = 'profilerx-content';
popupElement.appendChild(contentContainer);
// Show latest run by default
updateTabContent('latest');
} else {
// Just update the current tab's content
const activeTab = popupElement.querySelector('.profilerx-tab.active') as HTMLElement;
if (activeTab?.dataset.tabId) {
updateTabContent(activeTab.dataset.tabId);
}
}
}
+2
View File
@@ -0,0 +1,2 @@
import { ProfilerStats } from '../types.js';
export declare function showNodeAnalytics(element: HTMLElement, stats: ProfilerStats): void;
+97
View File
@@ -0,0 +1,97 @@
import { applyTableStyles, makeSortable } from '../ui/table.js';
import { createTimeRangeSelector, filterHistoryByTimeRange } from '../ui/timerange.js';
export function showNodeAnalytics(element, stats) {
const { container: rangeSelector, select: timeSelect } = createTimeRangeSelector();
function updateContent() {
// Filter history by time range
const history = filterHistoryByTimeRange(stats.history || [], timeSelect.value);
// Calculate node averages from filtered history
const nodeAverages = {};
history.forEach(run => {
const nodes = Object.values(run.nodes);
nodes.forEach(node => {
const nodeType = node.nodeType;
if (!nodeAverages[nodeType]) {
nodeAverages[nodeType] = {
total_time: 0,
vram_usage: 0,
ram_usage: 0,
count: 0,
cache_hits: 0
};
}
const stats = nodeAverages[nodeType];
stats.count++;
stats.total_time = (stats.total_time * (stats.count - 1) + (node.endTime - node.startTime)) / stats.count;
stats.vram_usage = (stats.vram_usage * (stats.count - 1) + (node.vramAfter - node.vramBefore)) / stats.count;
stats.ram_usage = (stats.ram_usage * (stats.count - 1) + (node.ramAfter - node.ramBefore)) / stats.count;
if (node.cacheHit)
stats.cache_hits++;
});
});
// Convert to array for sorting
const nodeStats = Object.entries(nodeAverages).map(([nodeType, stats]) => ({
nodeType,
...stats
}));
// Sort by execution time by default
nodeStats.sort((a, b) => b.total_time - a.total_time);
const contentDiv = element.querySelector('.node-analytics-content');
if (!contentDiv)
return;
const html = [`
<div style="margin: 8px 0;">
<table>
<thead>
<tr>
<th>Node Type</th>
<th class="sort-numeric">Avg Time (s)</th>
<th class="sort-numeric">Avg VRAM (GB)</th>
<th class="sort-numeric">Runs</th>
<th class="sort-numeric">Cache Hit %</th>
</tr>
</thead>
<tbody>
`];
nodeStats.forEach(node => {
const avgTime = (node.total_time / 1000).toFixed(3);
const avgVram = (node.vram_usage / 1e9).toFixed(2);
const cacheHitRate = ((node.cache_hits / node.count) * 100).toFixed(1);
html.push(`
<tr>
<td>${node.nodeType}</td>
<td>${avgTime}s</td>
<td>${avgVram}GB</td>
<td>${node.count}</td>
<td>${cacheHitRate}%</td>
</tr>
`);
});
html.push(`
</tbody>
</table>
</div>
`);
contentDiv.innerHTML = html.join('');
const table = contentDiv.querySelector('table');
if (table) {
applyTableStyles(table);
makeSortable(table);
}
}
// Set up the main container
element.innerHTML = `
<div style="padding: 8px;">
<h3>Node Performance Analytics</h3>
<div class="node-analytics-content"></div>
</div>
`;
// Insert range selector after heading
const heading = element.querySelector('h3');
if (heading)
heading.after(rangeSelector);
// Update when time range changes
timeSelect.addEventListener('change', updateContent);
// Initial content update
updateContent();
}
+113
View File
@@ -0,0 +1,113 @@
// Node analytics view
import { ProfilerStats, NodeProfile } from '../types.js';
import { applyTableStyles, makeSortable } from '../ui/table.js';
import { createTimeRangeSelector, filterHistoryByTimeRange } from '../ui/timerange.js';
export function showNodeAnalytics(element: HTMLElement, stats: ProfilerStats) {
const { container: rangeSelector, select: timeSelect } = createTimeRangeSelector();
function updateContent() {
// Filter history by time range
const history = filterHistoryByTimeRange(stats.history || [], timeSelect.value);
// Calculate node averages from filtered history
const nodeAverages: Record<string, any> = {};
history.forEach(run => {
const nodes = Object.values(run.nodes) as NodeProfile[];
nodes.forEach(node => {
const nodeType = node.nodeType;
if (!nodeAverages[nodeType]) {
nodeAverages[nodeType] = {
total_time: 0,
vram_usage: 0,
ram_usage: 0,
count: 0,
cache_hits: 0
};
}
const stats = nodeAverages[nodeType];
stats.count++;
stats.total_time = (stats.total_time * (stats.count - 1) + (node.endTime - node.startTime)) / stats.count;
stats.vram_usage = (stats.vram_usage * (stats.count - 1) + (node.vramAfter - node.vramBefore)) / stats.count;
stats.ram_usage = (stats.ram_usage * (stats.count - 1) + (node.ramAfter - node.ramBefore)) / stats.count;
if (node.cacheHit) stats.cache_hits++;
});
});
// Convert to array for sorting
const nodeStats = Object.entries(nodeAverages).map(([nodeType, stats]) => ({
nodeType,
...stats
}));
// Sort by execution time by default
nodeStats.sort((a, b) => b.total_time - a.total_time);
const contentDiv = element.querySelector('.node-analytics-content') as HTMLElement;
if (!contentDiv) return;
const html = [`
<div style="margin: 8px 0;">
<table>
<thead>
<tr>
<th>Node Type</th>
<th class="sort-numeric">Avg Time (s)</th>
<th class="sort-numeric">Avg VRAM (GB)</th>
<th class="sort-numeric">Runs</th>
<th class="sort-numeric">Cache Hit %</th>
</tr>
</thead>
<tbody>
`];
nodeStats.forEach(node => {
const avgTime = (node.total_time/1000).toFixed(3);
const avgVram = (node.vram_usage/1e9).toFixed(2);
const cacheHitRate = ((node.cache_hits / node.count) * 100).toFixed(1);
html.push(`
<tr>
<td>${node.nodeType}</td>
<td>${avgTime}s</td>
<td>${avgVram}GB</td>
<td>${node.count}</td>
<td>${cacheHitRate}%</td>
</tr>
`);
});
html.push(`
</tbody>
</table>
</div>
`);
contentDiv.innerHTML = html.join('');
const table = contentDiv.querySelector('table');
if (table) {
applyTableStyles(table as HTMLTableElement);
makeSortable(table as HTMLTableElement);
}
}
// Set up the main container
element.innerHTML = `
<div style="padding: 8px;">
<h3>Node Performance Analytics</h3>
<div class="node-analytics-content"></div>
</div>
`;
// Insert range selector after heading
const heading = element.querySelector('h3');
if (heading) heading.after(rangeSelector);
// Update when time range changes
timeSelect.addEventListener('change', updateContent);
// Initial content update
updateContent();
}
+2
View File
@@ -0,0 +1,2 @@
import { ProfilerStats } from '../types.js';
export declare function showHistoricalTrends(element: HTMLElement, stats: ProfilerStats): void;
+75
View File
@@ -0,0 +1,75 @@
import { applyTableStyles, makeSortable } from '../ui/table.js';
import { createTimeRangeSelector, filterHistoryByTimeRange } from '../ui/timerange.js';
export function showHistoricalTrends(element, stats) {
const { container: rangeSelector, select: timeSelect } = createTimeRangeSelector();
function updateContent() {
const history = filterHistoryByTimeRange(stats.history || [], timeSelect.value);
const workflow_avg = stats.workflow_averages || {};
const contentDiv = element.querySelector('.history-content');
if (!contentDiv)
return;
const html = [`
<div style="margin: 8px 0;">
<strong>Overall Averages:</strong>
<div style="margin: 8px 0; padding: 12px; background: var(--comfy-input-bg); border-radius: 4px;">
<div>Execution Time: ${(workflow_avg.total_time / 1000).toFixed(2)}s</div>
<div>VRAM Peak: ${(workflow_avg.vram_peak / 1e9).toFixed(2)}GB</div>
<div>RAM Peak: ${(workflow_avg.ram_peak / 1e9).toFixed(2)}GB</div>
<div>Total Runs: ${workflow_avg.count || 0}</div>
</div>
</div>
<div style="margin: 8px 0;">
<strong>Recent Runs:</strong>
<table>
<thead>
<tr>
<th class="sort-time">Time</th>
<th class="sort-numeric">Duration (s)</th>
<th class="sort-numeric">VRAM (GB)</th>
<th class="sort-numeric">Cache Hit %</th>
</tr>
</thead>
<tbody>
`];
history.slice().reverse().forEach(run => {
const date = new Date(run.startTime).toLocaleTimeString();
const duration = ((run.endTime - run.startTime) / 1000).toFixed(2);
const vram = (run.totalVramPeak / 1e9).toFixed(2);
const cacheRate = run.cacheHits / (run.cacheHits + run.cacheMisses);
html.push(`
<tr>
<td data-timestamp="${run.startTime}">${date}</td>
<td>${duration}</td>
<td>${vram}</td>
<td>${(cacheRate * 100).toFixed(1)}</td>
</tr>
`);
});
html.push(`
</tbody>
</table>
</div>
`);
contentDiv.innerHTML = html.join('');
const table = contentDiv.querySelector('table');
if (table) {
applyTableStyles(table);
makeSortable(table);
}
}
// Set up the main container
element.innerHTML = `
<div style="padding: 8px;">
<h3>Workflow Performance Trends</h3>
<div class="history-content"></div>
</div>
`;
// Insert range selector after heading
const heading = element.querySelector('h3');
if (heading)
heading.after(rangeSelector);
// Update when time range changes
timeSelect.addEventListener('change', updateContent);
// Initial content update
updateContent();
}
+87
View File
@@ -0,0 +1,87 @@
// Historical trends view
import { ProfilerStats } from '../types.js';
import { applyTableStyles, makeSortable } from '../ui/table.js';
import { createTimeRangeSelector, filterHistoryByTimeRange } from '../ui/timerange.js';
export function showHistoricalTrends(element: HTMLElement, stats: ProfilerStats) {
const { container: rangeSelector, select: timeSelect } = createTimeRangeSelector();
function updateContent() {
const history = filterHistoryByTimeRange(stats.history || [], timeSelect.value);
const workflow_avg = stats.workflow_averages || {};
const contentDiv = element.querySelector('.history-content') as HTMLElement;
if (!contentDiv) return;
const html = [`
<div style="margin: 8px 0;">
<strong>Overall Averages:</strong>
<div style="margin: 8px 0; padding: 12px; background: var(--comfy-input-bg); border-radius: 4px;">
<div>Execution Time: ${(workflow_avg.total_time/1000).toFixed(2)}s</div>
<div>VRAM Peak: ${(workflow_avg.vram_peak/1e9).toFixed(2)}GB</div>
<div>RAM Peak: ${(workflow_avg.ram_peak/1e9).toFixed(2)}GB</div>
<div>Total Runs: ${workflow_avg.count || 0}</div>
</div>
</div>
<div style="margin: 8px 0;">
<strong>Recent Runs:</strong>
<table>
<thead>
<tr>
<th class="sort-time">Time</th>
<th class="sort-numeric">Duration (s)</th>
<th class="sort-numeric">VRAM (GB)</th>
<th class="sort-numeric">Cache Hit %</th>
</tr>
</thead>
<tbody>
`];
history.slice().reverse().forEach(run => {
const date = new Date(run.startTime).toLocaleTimeString();
const duration = ((run.endTime - run.startTime)/1000).toFixed(2);
const vram = (run.totalVramPeak/1e9).toFixed(2);
const cacheRate = run.cacheHits / (run.cacheHits + run.cacheMisses);
html.push(`
<tr>
<td data-timestamp="${run.startTime}">${date}</td>
<td>${duration}</td>
<td>${vram}</td>
<td>${(cacheRate * 100).toFixed(1)}</td>
</tr>
`);
});
html.push(`
</tbody>
</table>
</div>
`);
contentDiv.innerHTML = html.join('');
const table = contentDiv.querySelector('table');
if (table) {
applyTableStyles(table as HTMLTableElement);
makeSortable(table as HTMLTableElement);
}
}
// Set up the main container
element.innerHTML = `
<div style="padding: 8px;">
<h3>Workflow Performance Trends</h3>
<div class="history-content"></div>
</div>
`;
// Insert range selector after heading
const heading = element.querySelector('h3');
if (heading) heading.after(rangeSelector);
// Update when time range changes
timeSelect.addEventListener('change', updateContent);
// Initial content update
updateContent();
}
+2
View File
@@ -0,0 +1,2 @@
import { ProfilerStats } from '../types.js';
export declare function showLatestRun(element: HTMLElement, stats: ProfilerStats): void;
+79
View File
@@ -0,0 +1,79 @@
import { applyTableStyles, makeSortable } from '../ui/table.js';
export function showLatestRun(element, stats) {
if (!stats || !stats.latest) {
element.innerHTML = '<div style="padding: 8px;">No profiling data available yet. Run a workflow to see stats.</div>';
return;
}
const profile = stats.latest;
const workflow_time = profile.endTime - profile.startTime;
const workflow_avg = stats.workflow_averages || {};
const html = [
`<div style="padding: 8px;">`,
`<h3>Latest Workflow Run</h3>`,
`<div style="margin: 8px 0;">`,
` <div>Last Run: ${(workflow_time / 1000).toFixed(2)}s</div>`,
` <div>Average: ${(workflow_avg.total_time / 1000 || 0).toFixed(2)}s (${workflow_avg.count || 0} runs)</div>`,
` <div>VRAM Peak: ${(profile.totalVramPeak / 1e9).toFixed(2)}GB</div>`,
` <div>RAM Peak: ${(profile.totalRamPeak / 1e9).toFixed(2)}GB</div>`,
` <div>Cache: ${profile.cacheHits} hits / ${profile.cacheMisses} misses</div>`,
`</div>`,
`<div style="margin: 8px 0;">`,
`<h4>Node Performance</h4>`,
`<table>`,
` <thead>`,
` <tr>`,
` <th>Node Type</th>`,
` <th class="sort-numeric">Time (s)</th>`,
` <th class="sort-numeric">VRAM (GB)</th>`,
` <th class="sort-numeric">RAM (GB)</th>`,
` <th>Cache</th>`,
` </tr>`,
` </thead>`,
` <tbody>`
];
// Sort nodes by execution time
const nodeStats = Object.entries(profile.nodes)
.map(([nodeId, node]) => {
const execution_time = node.endTime - node.startTime;
const vram_used = node.vramAfter - node.vramBefore;
const ram_used = node.ramAfter - node.ramBefore;
return {
execution_time,
vram_used,
ram_used,
nodeId,
node
};
})
.sort((a, b) => b.execution_time - a.execution_time);
// Add node stats as table rows
nodeStats.forEach(({ execution_time, vram_used, ram_used, nodeId, node }) => {
const cache_status = node.cacheHit ? '🟢' : '⚡';
const row_style = node.cacheHit ? 'opacity: 0.7;' : '';
html.push(`
<tr style="border-top: 1px solid var(--border-color); ${row_style}">
<td>
<div>${node.nodeType}</div>
<div style="opacity: 0.7; font-size: 10px;">(${nodeId})</div>
</td>
<td>${(execution_time / 1000).toFixed(3)}</td>
<td>${(vram_used / 1e9).toFixed(2)}</td>
<td>${(ram_used / 1e9).toFixed(2)}</td>
<td>${cache_status}</td>
</tr>
`);
});
html.push(`
</tbody>
</table>
</div>
</div>
`);
element.innerHTML = html.join('');
// After setting innerHTML, make the table sortable and apply styles
const table = element.querySelector('table');
if (table) {
applyTableStyles(table);
makeSortable(table);
}
}
+90
View File
@@ -0,0 +1,90 @@
// Latest run view
import { ProfilerStats, NodeProfile } from '../types.js';
import { applyTableStyles, makeSortable } from '../ui/table.js';
export function showLatestRun(element: HTMLElement, stats: ProfilerStats) {
if (!stats || !stats.latest) {
element.innerHTML = '<div style="padding: 8px;">No profiling data available yet. Run a workflow to see stats.</div>';
return;
}
const profile = stats.latest;
const workflow_time = profile.endTime - profile.startTime;
const workflow_avg = stats.workflow_averages || {};
const html = [
`<div style="padding: 8px;">`,
`<h3>Latest Workflow Run</h3>`,
`<div style="margin: 8px 0;">`,
` <div>Last Run: ${(workflow_time/1000).toFixed(2)}s</div>`,
` <div>Average: ${(workflow_avg.total_time/1000 || 0).toFixed(2)}s (${workflow_avg.count || 0} runs)</div>`,
` <div>VRAM Peak: ${(profile.totalVramPeak/1e9).toFixed(2)}GB</div>`,
` <div>RAM Peak: ${(profile.totalRamPeak/1e9).toFixed(2)}GB</div>`,
` <div>Cache: ${profile.cacheHits} hits / ${profile.cacheMisses} misses</div>`,
`</div>`,
`<div style="margin: 8px 0;">`,
`<h4>Node Performance</h4>`,
`<table>`,
` <thead>`,
` <tr>`,
` <th>Node Type</th>`,
` <th class="sort-numeric">Time (s)</th>`,
` <th class="sort-numeric">VRAM (GB)</th>`,
` <th class="sort-numeric">RAM (GB)</th>`,
` <th>Cache</th>`,
` </tr>`,
` </thead>`,
` <tbody>`
];
// Sort nodes by execution time
const nodeStats = Object.entries(profile.nodes)
.map(([nodeId, node]: [string, NodeProfile]) => {
const execution_time = node.endTime - node.startTime;
const vram_used = node.vramAfter - node.vramBefore;
const ram_used = node.ramAfter - node.ramBefore;
return {
execution_time,
vram_used,
ram_used,
nodeId,
node
};
})
.sort((a, b) => b.execution_time - a.execution_time);
// Add node stats as table rows
nodeStats.forEach(({ execution_time, vram_used, ram_used, nodeId, node }) => {
const cache_status = node.cacheHit ? '🟢' : '⚡';
const row_style = node.cacheHit ? 'opacity: 0.7;' : '';
html.push(`
<tr style="border-top: 1px solid var(--border-color); ${row_style}">
<td>
<div>${node.nodeType}</div>
<div style="opacity: 0.7; font-size: 10px;">(${nodeId})</div>
</td>
<td>${(execution_time/1000).toFixed(3)}</td>
<td>${(vram_used/1e9).toFixed(2)}</td>
<td>${(ram_used/1e9).toFixed(2)}</td>
<td>${cache_status}</td>
</tr>
`);
});
html.push(`
</tbody>
</table>
</div>
</div>
`);
element.innerHTML = html.join('');
// After setting innerHTML, make the table sortable and apply styles
const table = element.querySelector('table');
if (table) {
applyTableStyles(table as HTMLTableElement);
makeSortable(table as HTMLTableElement);
}
}
+1
View File
@@ -0,0 +1 @@
export declare function showSettings(element: HTMLElement): void;
+226
View File
@@ -0,0 +1,226 @@
import { api } from '../utils/api.js';
export function showSettings(element) {
let selectedArchive = null;
// Set up the main container
element.innerHTML = `
<div style="padding: 8px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
<button id="archiveBtn" class="comfyui-button">Archive Current History</button>
<a href="https://github.com/RyanOnTheInside/ComfyUI_ProfilerX" target="_blank" rel="noopener"
class="comfyui-button" style="display: inline-flex; align-items: center; gap: 8px; text-decoration: none;">
<i class="mdi mdi-github" style="font-size: 1.2em;"></i>
Star on GitHub
</a>
</div>
<div style="margin: 16px 0;">
<h4>History Archives</h4>
<table id="archivesTable">
<thead>
<tr>
<th>Filename</th>
<th>Created</th>
<th>Size</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="3" style="text-align: center; padding: 16px;">Loading archives...</td>
</tr>
</tbody>
</table>
<div style="margin-top: 8px; display: flex; gap: 8px;">
<button id="loadBtn" class="comfyui-button" disabled>Load Selected</button>
<button id="deleteBtn" class="comfyui-button" disabled>Delete Selected</button>
</div>
</div>
<div style="position: absolute; bottom: 8px; right: 8px; opacity: 0.8;">
Created by <a href="https://github.com/RyanOnTheInside" target="_blank" rel="noopener"
style="color: var(--comfy-color-accent); text-decoration: underline;">RyanOnTheInside</a>
</div>
</div>
`;
const loadBtn = element.querySelector('#loadBtn');
const deleteBtn = element.querySelector('#deleteBtn');
// Add styles for the table
const styleSheet = document.createElement('style');
styleSheet.textContent = `
.comfyui-button {
background-color: var(--comfy-input-bg);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 4px 8px;
color: var(--descrip-text);
cursor: pointer;
}
.comfyui-button:hover {
background-color: var(--comfy-input-bg-hover);
border-color: var(--border-color-hover);
}
.comfyui-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
#archivesTable {
width: 100%;
border-collapse: collapse;
font-size: 13px;
background: var(--comfy-input-bg);
border: 1px solid var(--border-color);
border-radius: 4px;
}
#archivesTable th,
#archivesTable td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
#archivesTable th {
background: rgba(0, 0, 0, 0.2);
font-weight: 600;
cursor: pointer;
user-select: none;
}
#archivesTable tbody tr {
cursor: pointer;
transition: background-color 0.2s;
background-color: transparent;
}
#archivesTable tbody tr:hover {
background-color: var(--comfy-input-bg-hover);
}
#archivesTable tbody tr.selected {
background-color: var(--comfy-color-selected);
}
#archivesTable tbody tr.selected:hover {
background-color: var(--comfy-color-selected-hover);
}
.filename-cell {
font-family: var(--comfy-font-mono);
}
`;
// Remove any existing ProfilerX styles to prevent duplicates
const existingStyles = document.querySelectorAll('style[data-profilerx-settings]');
existingStyles.forEach(style => style.remove());
// Add identifier to our stylesheet
styleSheet.setAttribute('data-profilerx-settings', '');
document.head.appendChild(styleSheet);
// Set up archive button
const archiveBtn = element.querySelector('#archiveBtn');
archiveBtn.onclick = async () => {
if (!confirm('Are you sure you want to archive the current history? This will save the current history to an archive file and clear the current history.')) {
return;
}
try {
const response = await api.fetchApi('/profilerx/archive', { method: 'POST' });
const result = await response.json();
if (result.success) {
refreshArchives();
// Refresh main view since history was cleared
document.dispatchEvent(new CustomEvent('profiler:historyLoaded'));
}
}
catch (error) {
console.error('Failed to create archive:', error);
}
};
// Set up load button
loadBtn.onclick = async () => {
if (!selectedArchive)
return;
if (!confirm('Loading this archive will save your current history to a new archive first. Continue?')) {
return;
}
try {
loadBtn.disabled = true;
deleteBtn.disabled = true;
const response = await api.fetchApi(`/profilerx/archive/${selectedArchive.filename}/load`, { method: 'POST' });
const result = await response.json();
if (result.success) {
// Wait a moment for the server to process the archive
await new Promise(resolve => setTimeout(resolve, 100));
// Refresh everything
await refreshArchives();
document.dispatchEvent(new CustomEvent('profiler:historyLoaded'));
}
else {
console.error('Failed to load archive:', result.error);
alert('Failed to load archive. Please check the console for details.');
}
}
catch (error) {
console.error('Failed to load archive:', error);
alert('Failed to load archive. Please check the console for details.');
}
finally {
loadBtn.disabled = false;
deleteBtn.disabled = false;
}
};
// Set up delete button
deleteBtn.onclick = async () => {
if (!selectedArchive)
return;
if (!confirm('Are you sure you want to delete this archive? This cannot be undone.')) {
return;
}
try {
const response = await api.fetchApi(`/profilerx/archive/${selectedArchive.filename}`, { method: 'DELETE' });
const result = await response.json();
if (result.success) {
refreshArchives();
}
}
catch (error) {
console.error('Failed to delete archive:', error);
}
};
// Function to refresh archives list
async function refreshArchives() {
const tbody = element.querySelector('#archivesTable tbody');
try {
const response = await api.fetchApi('/profilerx/archives');
const archives = await response.json();
tbody.innerHTML = '';
selectedArchive = null;
loadBtn.disabled = true;
deleteBtn.disabled = true;
if (archives.length === 0) {
const row = tbody.insertRow();
const cell = row.insertCell();
cell.colSpan = 3;
cell.textContent = 'No archives found';
cell.style.textAlign = 'center';
cell.style.padding = '16px';
return;
}
archives.forEach((archive) => {
const row = tbody.insertRow();
const date = new Date(archive.created * 1000).toLocaleString();
const size = Math.round(archive.size / 1024);
row.innerHTML = `
<td class="filename-cell">${archive.filename}</td>
<td>${date}</td>
<td>${size}KB</td>
`;
row.onclick = () => {
// Clear previous selection
tbody.querySelectorAll('tr').forEach(r => r.classList.remove('selected'));
// Set new selection
row.classList.add('selected');
selectedArchive = archive;
loadBtn.disabled = false;
deleteBtn.disabled = false;
};
});
}
catch (error) {
console.error('Failed to fetch archives:', error);
tbody.innerHTML = '<tr><td colspan="3" style="text-align: center; padding: 16px;">Failed to load archives</td></tr>';
}
}
// Initial load of archives
refreshArchives();
}
+245
View File
@@ -0,0 +1,245 @@
// Settings view
import { Archive } from '../types.js';
import { applyTableStyles, makeSortable } from '../ui/table.js';
import { api } from '../utils/api.js';
export function showSettings(element: HTMLElement) {
let selectedArchive: Archive | null = null;
// Set up the main container
element.innerHTML = `
<div style="padding: 8px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
<button id="archiveBtn" class="comfyui-button">Archive Current History</button>
<a href="https://github.com/RyanOnTheInside/ComfyUI_ProfilerX" target="_blank" rel="noopener"
class="comfyui-button" style="display: inline-flex; align-items: center; gap: 8px; text-decoration: none;">
<i class="mdi mdi-github" style="font-size: 1.2em;"></i>
Star on GitHub
</a>
</div>
<div style="margin: 16px 0;">
<h4>History Archives</h4>
<table id="archivesTable">
<thead>
<tr>
<th>Filename</th>
<th>Created</th>
<th>Size</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="3" style="text-align: center; padding: 16px;">Loading archives...</td>
</tr>
</tbody>
</table>
<div style="margin-top: 8px; display: flex; gap: 8px;">
<button id="loadBtn" class="comfyui-button" disabled>Load Selected</button>
<button id="deleteBtn" class="comfyui-button" disabled>Delete Selected</button>
</div>
</div>
<div style="position: absolute; bottom: 8px; right: 8px; opacity: 0.8;">
Created by <a href="https://github.com/RyanOnTheInside" target="_blank" rel="noopener"
style="color: var(--comfy-color-accent); text-decoration: underline;">RyanOnTheInside</a>
</div>
</div>
`;
const loadBtn = element.querySelector('#loadBtn') as HTMLButtonElement;
const deleteBtn = element.querySelector('#deleteBtn') as HTMLButtonElement;
// Add styles for the table
const styleSheet = document.createElement('style');
styleSheet.textContent = `
.comfyui-button {
background-color: var(--comfy-input-bg);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 4px 8px;
color: var(--descrip-text);
cursor: pointer;
}
.comfyui-button:hover {
background-color: var(--comfy-input-bg-hover);
border-color: var(--border-color-hover);
}
.comfyui-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
#archivesTable {
width: 100%;
border-collapse: collapse;
font-size: 13px;
background: var(--comfy-input-bg);
border: 1px solid var(--border-color);
border-radius: 4px;
}
#archivesTable th,
#archivesTable td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
#archivesTable th {
background: rgba(0, 0, 0, 0.2);
font-weight: 600;
cursor: pointer;
user-select: none;
}
#archivesTable tbody tr {
cursor: pointer;
transition: background-color 0.2s;
background-color: transparent;
}
#archivesTable tbody tr:hover {
background-color: var(--comfy-input-bg-hover);
}
#archivesTable tbody tr.selected {
background-color: var(--comfy-color-selected);
}
#archivesTable tbody tr.selected:hover {
background-color: var(--comfy-color-selected-hover);
}
.filename-cell {
font-family: var(--comfy-font-mono);
}
`;
// Remove any existing ProfilerX styles to prevent duplicates
const existingStyles = document.querySelectorAll('style[data-profilerx-settings]');
existingStyles.forEach(style => style.remove());
// Add identifier to our stylesheet
styleSheet.setAttribute('data-profilerx-settings', '');
document.head.appendChild(styleSheet);
// Set up archive button
const archiveBtn = element.querySelector('#archiveBtn') as HTMLButtonElement;
archiveBtn.onclick = async () => {
if (!confirm('Are you sure you want to archive the current history? This will save the current history to an archive file and clear the current history.')) {
return;
}
try {
const response = await api.fetchApi('/profilerx/archive', { method: 'POST' });
const result = await response.json();
if (result.success) {
refreshArchives();
// Refresh main view since history was cleared
document.dispatchEvent(new CustomEvent('profiler:historyLoaded'));
}
} catch (error) {
console.error('Failed to create archive:', error);
}
};
// Set up load button
loadBtn.onclick = async () => {
if (!selectedArchive) return;
if (!confirm('Loading this archive will save your current history to a new archive first. Continue?')) {
return;
}
try {
loadBtn.disabled = true;
deleteBtn.disabled = true;
const response = await api.fetchApi(`/profilerx/archive/${selectedArchive.filename}/load`, { method: 'POST' });
const result = await response.json();
if (result.success) {
// Wait a moment for the server to process the archive
await new Promise(resolve => setTimeout(resolve, 100));
// Refresh everything
await refreshArchives();
document.dispatchEvent(new CustomEvent('profiler:historyLoaded'));
} else {
console.error('Failed to load archive:', result.error);
alert('Failed to load archive. Please check the console for details.');
}
} catch (error) {
console.error('Failed to load archive:', error);
alert('Failed to load archive. Please check the console for details.');
} finally {
loadBtn.disabled = false;
deleteBtn.disabled = false;
}
};
// Set up delete button
deleteBtn.onclick = async () => {
if (!selectedArchive) return;
if (!confirm('Are you sure you want to delete this archive? This cannot be undone.')) {
return;
}
try {
const response = await api.fetchApi(`/profilerx/archive/${selectedArchive.filename}`, { method: 'DELETE' });
const result = await response.json();
if (result.success) {
refreshArchives();
}
} catch (error) {
console.error('Failed to delete archive:', error);
}
};
// Function to refresh archives list
async function refreshArchives() {
const tbody = element.querySelector('#archivesTable tbody') as HTMLTableSectionElement;
try {
const response = await api.fetchApi('/profilerx/archives');
const archives = await response.json() as Archive[];
tbody.innerHTML = '';
selectedArchive = null;
loadBtn.disabled = true;
deleteBtn.disabled = true;
if (archives.length === 0) {
const row = tbody.insertRow();
const cell = row.insertCell();
cell.colSpan = 3;
cell.textContent = 'No archives found';
cell.style.textAlign = 'center';
cell.style.padding = '16px';
return;
}
archives.forEach((archive: Archive) => {
const row = tbody.insertRow();
const date = new Date(archive.created * 1000).toLocaleString();
const size = Math.round(archive.size / 1024);
row.innerHTML = `
<td class="filename-cell">${archive.filename}</td>
<td>${date}</td>
<td>${size}KB</td>
`;
row.onclick = () => {
// Clear previous selection
tbody.querySelectorAll('tr').forEach(r => r.classList.remove('selected'));
// Set new selection
row.classList.add('selected');
selectedArchive = archive;
loadBtn.disabled = false;
deleteBtn.disabled = false;
};
});
} catch (error) {
console.error('Failed to fetch archives:', error);
tbody.innerHTML = '<tr><td colspan="3" style="text-align: center; padding: 16px;">Failed to load archives</td></tr>';
}
}
// Initial load of archives
refreshArchives();
}