initial commit
This commit is contained in:
+93
@@ -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/
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
+16
@@ -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
|
||||
+17
@@ -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" %*
|
||||
+28
@@ -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
|
||||
+16
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,50 @@
|
||||
|
||||
# TypeScript
|
||||
|
||||
[](https://github.com/microsoft/TypeScript/actions?query=workflow%3ACI)
|
||||
[](https://www.npmjs.com/package/typescript)
|
||||
[](https://www.npmjs.com/package/typescript)
|
||||
[](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
@@ -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 -->
|
||||
+193
File diff suppressed because one or more lines are too long
+2
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env node
|
||||
require('../lib/tsc.js')
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env node
|
||||
require('../lib/tsserver.js')
|
||||
+120
@@ -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"
|
||||
}
|
||||
Generated
+29
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
psutil>=5.9.0
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Vendored
+30
@@ -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;
|
||||
}
|
||||
Vendored
+28
@@ -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>;
|
||||
}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
import { NodeProfile, WorkflowProfile } from './types.js';
|
||||
export type { NodeProfile, WorkflowProfile };
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
Vendored
+105
@@ -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;
|
||||
}
|
||||
}
|
||||
Vendored
+41
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Type definitions for ProfilerX
|
||||
export {};
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
export declare function createPerformanceMonitor(): {
|
||||
container: HTMLDivElement;
|
||||
elements: {
|
||||
button: HTMLButtonElement;
|
||||
popup: HTMLDivElement;
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
interface PopupElements {
|
||||
button: HTMLButtonElement;
|
||||
popup: HTMLDivElement;
|
||||
}
|
||||
export declare function createPopup(): {
|
||||
container: HTMLDivElement;
|
||||
elements: PopupElements;
|
||||
};
|
||||
export {};
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
});
|
||||
}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
export declare function applyTableStyles(table: HTMLTableElement): void;
|
||||
export declare function makeSortable(table: HTMLTableElement): void;
|
||||
+190
@@ -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
@@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
export declare function createTabbedInterface(popupElement: HTMLElement): HTMLDivElement;
|
||||
export declare function switchTab(tabId: string): void;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Vendored
+10
@@ -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[];
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
export declare const app: any;
|
||||
export declare const api: any;
|
||||
export declare function fetchProfilerStats(): Promise<any>;
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
export declare function debug(...args: any[]): void;
|
||||
@@ -0,0 +1,4 @@
|
||||
// Debug logging utilities
|
||||
export function debug(...args) {
|
||||
// console.log("[ProfilerX]", ...args);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Debug logging utilities
|
||||
|
||||
export function debug(...args: any[]) {
|
||||
// console.log("[ProfilerX]", ...args);
|
||||
return;
|
||||
}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
export declare function updateTabContent(tabId: string): Promise<void>;
|
||||
export declare function updateMenuStats(popupElement: HTMLElement): void;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
import { ProfilerStats } from '../types.js';
|
||||
export declare function showNodeAnalytics(element: HTMLElement, stats: ProfilerStats): void;
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
import { ProfilerStats } from '../types.js';
|
||||
export declare function showHistoricalTrends(element: HTMLElement, stats: ProfilerStats): void;
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
import { ProfilerStats } from '../types.js';
|
||||
export declare function showLatestRun(element: HTMLElement, stats: ProfilerStats): void;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
export declare function showSettings(element: HTMLElement): void;
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user