Compare commits

..

15 Commits

Author SHA1 Message Date
Ethanfel 62e4b9df8c fix(workflow): skip Manager queue ops when Manager is mid-operation
Publish to Comfy registry / Publish Custom Node to registry (push) Has been cancelled
2026-06-21 13:00:35 +02:00
Ethanfel 7cd67cdda1 docs: document workflow tab + trial-enable; bump to 1.3.0 2026-06-21 12:55:18 +02:00
Ethanfel c6601bc86f feat(workflow): auto-disable expired trial packages on load 2026-06-21 12:54:02 +02:00
Ethanfel c7b06f5906 feat(workflow): missing nodes defer to ComfyUI Manager installer 2026-06-21 12:52:31 +02:00
Ethanfel 427a509e4c feat(workflow): enable temporary (7d trial) / permanent actions 2026-06-21 12:48:38 +02:00
Ethanfel fed626685b feat(workflow): workflow tab UI + auto-open on load 2026-06-21 12:45:49 +02:00
Ethanfel 743741afc6 feat(workflow): classify unresolved nodes into disabled vs missing 2026-06-21 12:38:48 +02:00
Ethanfel b5e7bf204d feat(workflow): detect unresolved node types on workflow load 2026-06-21 12:36:18 +02:00
Ethanfel 9127f8121d feat(trials): boot tick, usage reset hook, and trial routes 2026-06-21 12:28:36 +02:00
Ethanfel c53fdd8560 feat(trials): stop_trial and clear trials on reset 2026-06-21 12:27:37 +02:00
Ethanfel 4ebad1bd6c feat(trials): reset_trials_for zeroes counter on use 2026-06-21 12:27:05 +02:00
Ethanfel 69eef149eb feat(trials): tick_boot_days counts distinct boot-days 2026-06-21 12:26:31 +02:00
Ethanfel 7b5fb32b31 feat(trials): add trial_packages table, start_trial, get_trials 2026-06-21 12:25:52 +02:00
Ethanfel caaaaa3b24 docs: implementation plan for workflow tab + trial-enable
Bite-sized TDD plan (13 tasks): backend trial state + boot tick + routes,
a spike to verify Manager's enable payload, frontend detection/tab/actions/
expiry, and docs/version. Backend is fully test-driven; frontend is
manual-verify (no JS harness).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 12:01:29 +02:00
Ethanfel 1a2bd2bcef docs: design for workflow tab + temporary trial-enable
Approved brainstorming design: a Workflow tab that splits a loaded
workflow's unresolved nodes into Missing (defer to Manager) and Disabled
(enable temporarily under a 7 distinct-boot-day rolling trial, or
permanently). Backend tracks trial state and counts boot-days; frontend
drives Manager enable/disable (Approach A).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 11:58:45 +02:00
8 changed files with 1509 additions and 19 deletions
+34 -2
View File
@@ -16,6 +16,7 @@ A ComfyUI custom node package that silently tracks which nodes, packages, and mo
- **Uninstall detection** — removed packages/models are flagged separately, historical data preserved - **Uninstall detection** — removed packages/models are flagged separately, historical data preserved
- **Expandable detail** — click any package to see individual node-level stats - **Expandable detail** — click any package to see individual node-level stats
- **One-click disable** — disable unused packages straight from the dialog via ComfyUI Manager (per-package or in bulk), reversible at any time - **One-click disable** — disable unused packages straight from the dialog via ComfyUI Manager (per-package or in bulk), reversible at any time
- **Workflow tab** — on loading a workflow, splits unresolved nodes into *Missing* (install via Manager) and *Disabled*, with a temporary **Enable 7d** trial that auto-disables packages left unused
- **Non-blocking** — DB writes happen in a background thread, no impact on workflow execution - **Non-blocking** — DB writes happen in a background thread, no impact on workflow execution
## Package Classification ## Package Classification
@@ -58,7 +59,7 @@ Restart ComfyUI. Tracking starts immediately and silently.
### UI ### UI
Click the **Node Stats** button (bar chart icon) in the ComfyUI top menu bar. A dialog opens with two tabs: Click the **Node Stats** button (bar chart icon) in the ComfyUI top menu bar. A dialog opens with three tabs:
**Nodes tab** **Nodes tab**
- Summary bar with counts for each classification tier - Summary bar with counts for each classification tier
@@ -79,6 +80,33 @@ package, plus a **Disable all** button per section. Disabling:
If ComfyUI Manager is not installed, the disable buttons are hidden and stats work as before. If ComfyUI Manager is not installed, the disable buttons are hidden and stats work as before.
### Workflow tab & temporary enable
Whenever you load a workflow, the extension scans for node types the running
ComfyUI can't resolve and, if any are found, opens the dialog on the **Workflow**
tab. Unresolved nodes are split into two groups:
- **Missing** — the owning package isn't installed. Install is handled by
[ComfyUI Manager](https://github.com/ltdrdata/ComfyUI-Manager) like always: the
**Install** button opens Manager's Custom Nodes Manager (use its *Missing*
filter).
- **Disabled** — the package is installed but currently disabled. Each row offers:
- **Enable 7d** — re-enable the package and start a *temporary trial*.
- **Enable** — re-enable permanently (no trial).
**The temporary trial** is a rolling budget of **7 distinct boot-days**. A
"boot-day" is counted at most once per calendar day, the first time ComfyUI
starts that day — so the clock measures days you actually run ComfyUI, not wall
time. **Any execution that uses the package resets the counter to zero.** If a
trial package goes its full budget of distinct boot-days without being used, it
is **auto-disabled on the next UI load** (handed to ComfyUI Manager exactly like
a manual disable) and the trial is cleared. As with any disable, a ComfyUI
restart is required to fully unload it.
Re-enabling and auto-disabling both go through ComfyUI Manager, so the whole
Workflow tab is inert when Manager is not installed (the backend still tracks
trial state, but no enable/disable actions are offered).
**Models tab** **Models tab**
- Summary bar with counts for each tier across all model types - Summary bar with counts for each tier across all model types
- Sections per model type (checkpoints, vae, controlnet, …) - Sections per model type (checkpoints, vae, controlnet, …)
@@ -92,6 +120,9 @@ If ComfyUI Manager is not installed, the disable buttons are hidden and stats wo
| `/nodes-stats/usage` | GET | Raw per-node usage data | | `/nodes-stats/usage` | GET | Raw per-node usage data |
| `/nodes-stats/models` | GET | Per-type model stats with classification | | `/nodes-stats/models` | GET | Per-type model stats with classification |
| `/nodes-stats/reset` | POST | Clear all tracked data | | `/nodes-stats/reset` | POST | Clear all tracked data |
| `/nodes-stats/trials` | GET | Active temporary-enable trials with `days_remaining`/`expired` |
| `/nodes-stats/trials/start` | POST | Begin/restart a trial — body `{"package": "<dir-name>"}` |
| `/nodes-stats/trials/stop` | POST | End a trial (made permanent or disabled) — body `{"package": "<dir-name>"}` |
```bash ```bash
curl http://localhost:8188/nodes-stats/packages | python3 -m json.tool curl http://localhost:8188/nodes-stats/packages | python3 -m json.tool
@@ -178,6 +209,7 @@ All data is stored in `<ComfyUI user dir>/nodes_stats/usage_stats.db` (survives
| `node_usage` | Per-node: class_type, package, execution count, first/last seen | | `node_usage` | Per-node: class_type, package, execution count, first/last seen |
| `prompt_log` | Per-prompt: timestamp, JSON array of all class_types used | | `prompt_log` | Per-prompt: timestamp, JSON array of all class_types used |
| `model_usage` | Per-model: filename, type, execution count, first/last seen | | `model_usage` | Per-model: filename, type, execution count, first/last seen |
| `trial_packages` | Per temporary-enable trial: package, enable date, unused-boot-day counter, budget |
Use `POST /nodes-stats/reset` to clear all data and start fresh. Use `POST /nodes-stats/reset` to clear all data and start fresh.
@@ -208,7 +240,7 @@ timeout so the kernel keeps the listing warm across restarts, e.g.
__init__.py Entry point: prompt handler, API routes __init__.py Entry point: prompt handler, API routes
mapper.py class_type → package mapping; model filename → type mapping mapper.py class_type → package mapping; model filename → type mapping
tracker.py SQLite persistence and stats aggregation tracker.py SQLite persistence and stats aggregation
js/nodes_stats.js Frontend: menu button + stats dialog (Nodes/Models tabs) js/nodes_stats.js Frontend: menu button + stats dialog (Nodes/Models/Workflow tabs)
tools/diagnose_model_scan.py Standalone: diagnose slow model-folder scans at boot tools/diagnose_model_scan.py Standalone: diagnose slow model-folder scans at boot
pyproject.toml Package metadata pyproject.toml Package metadata
tests/ Unit tests for tracker and mapper tests/ Unit tests for tracker and mapper
+51
View File
@@ -44,6 +44,13 @@ def _record_prompt(class_types, prompt):
tracker.record_usage(class_types, mapper) tracker.record_usage(class_types, mapper)
except Exception: except Exception:
logger.warning("nodes-stats: error recording node usage", exc_info=True) logger.warning("nodes-stats: error recording node usage", exc_info=True)
try:
packages = {mapper.get_package(ct) for ct in class_types}
packages.discard("__builtin__")
packages.discard("__unknown__")
tracker.reset_trials_for(packages)
except Exception:
logger.warning("nodes-stats: error resetting trials", exc_info=True)
try: try:
models = model_mapper.extract_models_from_prompt(prompt) models = model_mapper.extract_models_from_prompt(prompt)
if models: if models:
@@ -52,6 +59,13 @@ def _record_prompt(class_types, prompt):
logger.warning("nodes-stats: error recording model usage", exc_info=True) logger.warning("nodes-stats: error recording model usage", exc_info=True)
# Age temporary-enable trials once per process start (one "boot").
try:
tracker.tick_boot_days()
except Exception:
logger.warning("nodes-stats: error ticking trial boot days", exc_info=True)
PromptServer.instance.add_on_prompt_handler(on_prompt_handler) PromptServer.instance.add_on_prompt_handler(on_prompt_handler)
@@ -99,3 +113,40 @@ async def reset_stats(request):
except Exception: except Exception:
logger.error("nodes-stats: error resetting stats", exc_info=True) logger.error("nodes-stats: error resetting stats", exc_info=True)
return web.json_response({"error": "internal error"}, status=500) return web.json_response({"error": "internal error"}, status=500)
@routes.get("/nodes-stats/trials")
async def get_trials(request):
try:
return web.json_response(tracker.get_trials())
except Exception:
logger.error("nodes-stats: error getting trials", exc_info=True)
return web.json_response({"error": "internal error"}, status=500)
@routes.post("/nodes-stats/trials/start")
async def start_trial(request):
try:
data = await request.json()
package = data.get("package")
if not package:
return web.json_response({"error": "package required"}, status=400)
tracker.start_trial(package)
return web.json_response({"status": "ok"})
except Exception:
logger.error("nodes-stats: error starting trial", exc_info=True)
return web.json_response({"error": "internal error"}, status=500)
@routes.post("/nodes-stats/trials/stop")
async def stop_trial(request):
try:
data = await request.json()
package = data.get("package")
if not package:
return web.json_response({"error": "package required"}, status=400)
tracker.stop_trial(package)
return web.json_response({"status": "ok"})
except Exception:
logger.error("nodes-stats: error stopping trial", exc_info=True)
return web.json_response({"error": "internal error"}, status=500)
@@ -0,0 +1,149 @@
# Design: Workflow tab + temporary trial-enable for disabled packages
Date: 2026-06-21
Status: Approved
## Summary
When a loaded workflow references node types that aren't currently resolvable,
Node Stats shows a **Workflow** tab that splits them into:
- **Missing** — the owning package is not installed (or unknown). Handled by
ComfyUI Manager as usual (install).
- **Disabled** — the owning package is installed but disabled (in
`custom_nodes/.disabled`). Offers two actions:
- **Enable temporarily** — re-enable the package under a rolling 7-day trial.
- **Enable permanently** — normal re-enable, never auto-disabled.
A temporarily-enabled package auto-disables again if it goes **7 distinct
boot-days without being used** in an executed prompt. Any execution use resets
the counter back to 7 (rolling window), so a package stays enabled as long as it
keeps getting used.
## Decisions (from brainstorming)
- **Graduation model:** rolling. Each execution use resets the counter to the
full budget (7). 7 distinct boot-days unused → auto-disable. No permanent
"graduation"; a trial package stays trial-managed, kept alive by use.
- **"Used" means:** the package's node appears in an *executed/queued* prompt.
Reuses the existing usage tracking. Loading a workflow does not count.
- **Granularity:** the tab lists individual node types ("list by node"), but
every action resolves to and operates on the owning *package* (Manager works
per package). Trial state is tracked per package.
- **Surfacing:** on workflow load, if any node is disabled/missing, auto-open
the Node Stats dialog to the Workflow tab.
- **Disabled actions:** both "Enable temporarily" (trial) and "Enable
permanently".
- **Missing:** defer to ComfyUI Manager (install), as usual.
- **Orchestration (Approach A):** the Python backend only tracks trial state,
counts boot-days, resets on use, and flags expiry. The frontend performs all
ComfyUI-Manager enable/disable mutations (server is up, reuses the proven
disable code, no coupling to Manager internals). Day-counting stays accurate
across headless restarts; the actual auto-disable executes the next time the
web UI loads.
## Data model
New SQLite table `trial_packages` in the existing DB:
| column | meaning |
|---|---|
| `package` (PK) | directory name (matches our package keys) |
| `enabled_at` | ISO timestamp the trial started |
| `last_use_day` | `YYYY-MM-DD` of last execution use (init = enable day) |
| `last_boot_day` | `YYYY-MM-DD` of last boot-day counted |
| `unused_boot_days` | counter, 0..budget |
| `budget` | distinct boot-days allowed unused (default 7) |
### Lifecycle
- **Start trial** (after frontend temp-enable): upsert with
`unused_boot_days=0`, `last_boot_day=today`, `last_use_day=today`,
`enabled_at=now`. The enable day never counts toward expiry ("not the same
day").
- **Boot tick** (`tracker.tick_boot_days()`, called once at `__init__` import):
for each trial row, if `last_boot_day != today`: `unused_boot_days += 1`,
`last_boot_day = today`.
- **Expiry:** computed, not stored — `expired = unused_boot_days >= budget`.
- **Use reset** (`reset_trials_for(packages)`, called from `_record_prompt`
after `record_usage`): for each used package on trial, set
`unused_boot_days = 0`, `last_use_day = today`.
- **Stop trial** (`stop_trial(package)`): delete the row. Called after permanent
enable, after the frontend disables an expired pack, or if re-disabled.
Disabling an already-disabled package via Manager is a no-op, so a stale trial
row (e.g. package disabled out-of-band) resolves cleanly on next expiry pass.
## Backend (Python)
- `tracker.py`: add table to `SCHEMA`; methods `start_trial`, `stop_trial`,
`tick_boot_days`, `reset_trials_for(packages)`, `get_trials`.
- `__init__.py`:
- At import: `tracker.tick_boot_days()` wrapped in try/except (never blocks
extension load).
- In `_record_prompt`: after `record_usage`, map used class_types → packages
and call `tracker.reset_trials_for(packages)`.
- Routes:
- `GET /nodes-stats/trials` → `[{package, unused_boot_days, budget,
days_remaining, expired, enabled_at, last_use_day}, ...]`
- `POST /nodes-stats/trials/start` `{package}` → upsert trial
- `POST /nodes-stats/trials/stop` `{package}` → delete trial
The backend never calls ComfyUI Manager.
## Frontend (JS)
- **WF-load hook:** wrap `app.loadGraphData` (and/or graph-changed event);
collect node types present in the graph but not in
`LiteGraph.registered_node_types` (unresolved).
- **Classify** unresolved types using:
- `/customnode/getmappings?mode=local` → class_type → pack key
- `/customnode/getlist?mode=local&skip_update=true` → pack install state
- pack state `disabled` → Disabled bucket; `not-installed`/unmappable →
Missing bucket. (Reconcile the getmappings pack key against getlist entries
by id/cnr_id/aux_id — verify field names during implementation.)
- **Workflow tab**, auto-opened when ≥1 unresolved node:
- Disabled rows (by node): `[Enable 7d]` (temp → Manager enable, then
`trials/start`), `[Enable]` (permanent → Manager enable, then `trials/stop`
if previously on trial). Show "on trial — N day-boots left" when applicable.
- Missing rows (by node): `[Install]` → defer to Manager install.
- Actions resolve to the owning package and dedupe.
- **Expiry execution:** on app load / dialog open, `GET /nodes-stats/trials`;
for `expired` packs, disable via Manager (reuse existing disable code) →
`trials/stop` → toast "auto-disabled N unused trial package(s)".
- Enable/disable need a restart to apply in the running session → reuse the
existing restart banner.
## Error handling
- ComfyUI Manager absent → feature inert (no classification, no actions), same
as the existing disable feature.
- HTTP/DB failures → toast + log; never corrupt trial state. Failed expiry
disable keeps the row for a later session (retry).
- Package disabled out-of-band → expiry disable no-ops; row cleaned on stop.
## Testing
- `tests/test_trials.py` (pure logic, mocked dates like existing tests):
- start_trial initializes counters; enable day not counted
- tick_boot_days increments only on a new calendar day (same-day reboots = 1)
- reset_trials_for zeroes the counter
- expiry triggers at `unused_boot_days >= budget`
- start/stop/get round-trips
- Frontend verified manually (graph load → tab → enable/disable → expiry).
## Known implementation risk
The **enable** payload (`/manager/queue/install` + `skip_post_install=true`)
needs the correct `id`/`version` for disabled packs — the same class of detail
as the disable-payload bug already fixed. Verify empirically against a live
disabled package during implementation rather than assuming.
## Files touched
- `tracker.py` — trial table + methods
- `__init__.py` — boot tick, usage reset hook, 3 routes
- `js/nodes_stats.js` — WF-load hook, classification, Workflow tab, actions,
expiry execution
- `tests/test_trials.py` — backend unit tests
- `README.md`, `pyproject.toml` — docs + version bump
+751
View File
@@ -0,0 +1,751 @@
# Workflow Tab + Temporary Trial-Enable Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add a Node Stats "Workflow" tab that splits a loaded workflow's unresolved nodes into Missing (defer to ComfyUI Manager) and Disabled (re-enable temporarily under a 7 distinct-boot-day rolling trial, or permanently), auto-disabling unused trial packages.
**Architecture:** Approach A — the Python backend only tracks trial state in SQLite, counts boot-days (once per `__init__` import), resets a per-package counter when the existing usage tracker sees the package executed, and exposes trial state via three routes. The frontend (JS) detects unresolved graph nodes, classifies them via ComfyUI Manager's `getmappings`/`getlist`, renders the tab, and performs all Manager enable/disable mutations (reusing the existing disable code). The backend never calls Manager.
**Tech Stack:** Python 3.12 (stdlib `sqlite3`), aiohttp routes (ComfyUI `PromptServer`), vanilla JS (ComfyUI frontend extension), pytest.
**Design doc:** `docs/plans/2026-06-21-trial-enable-design.md`
**Reference for date-mocking in tests:** existing `tests/test_model_tracker.py` patches `tracker.datetime`.
---
## Phase 1 — Backend trial state (pure logic, TDD)
All Phase 1 tasks edit `tracker.py` and `tests/test_trials.py`. Run tests with the project's `python -m pytest`.
### Task 1: Trial table + `start_trial` / `get_trials`
**Files:**
- Modify: `tracker.py` (add to `SCHEMA`, add `DEFAULT_TRIAL_BUDGET`, methods)
- Create: `tests/test_trials.py`
**Step 1: Write the failing test**
```python
# tests/test_trials.py
import pytest
from datetime import datetime, timezone, timedelta
from unittest.mock import patch
from tracker import UsageTracker, DEFAULT_TRIAL_BUDGET
@pytest.fixture
def tracker(tmp_path):
return UsageTracker(db_path=str(tmp_path / "test.db"))
def test_start_trial_initializes(tracker):
tracker.start_trial("Some-Pack")
trials = tracker.get_trials()
assert len(trials) == 1
t = trials[0]
assert t["package"] == "Some-Pack"
assert t["unused_boot_days"] == 0
assert t["budget"] == DEFAULT_TRIAL_BUDGET
assert t["days_remaining"] == DEFAULT_TRIAL_BUDGET
assert t["expired"] is False
def test_start_trial_is_idempotent_resets(tracker):
tracker.start_trial("Some-Pack")
tracker.start_trial("Some-Pack")
assert len(tracker.get_trials()) == 1
```
**Step 2: Run test to verify it fails**
Run: `python -m pytest tests/test_trials.py -v`
Expected: FAIL (cannot import `DEFAULT_TRIAL_BUDGET` / no `start_trial`).
**Step 3: Write minimal implementation**
In `tracker.py`, after the `SCHEMA` string add a table (append inside the existing `SCHEMA` triple-quoted block, before the closing `"""`):
```sql
CREATE TABLE IF NOT EXISTS trial_packages (
package TEXT PRIMARY KEY,
enabled_at TEXT NOT NULL,
last_use_day TEXT NOT NULL,
last_boot_day TEXT NOT NULL,
unused_boot_days INTEGER NOT NULL DEFAULT 0,
budget INTEGER NOT NULL DEFAULT 7
);
```
Add a module constant near `EXCLUDED_PACKAGES`:
```python
DEFAULT_TRIAL_BUDGET = 7
```
Add methods to `UsageTracker`:
```python
def start_trial(self, package, budget=DEFAULT_TRIAL_BUDGET):
"""Begin/restart a temporary-enable trial. The enable day is not counted."""
now = datetime.now(timezone.utc)
today = now.date().isoformat()
with self._lock:
self._ensure_db()
conn = self._connect()
try:
conn.execute(
"""INSERT INTO trial_packages
(package, enabled_at, last_use_day, last_boot_day, unused_boot_days, budget)
VALUES (?, ?, ?, ?, 0, ?)
ON CONFLICT(package) DO UPDATE SET
enabled_at = excluded.enabled_at,
last_use_day = excluded.last_use_day,
last_boot_day = excluded.last_boot_day,
unused_boot_days = 0,
budget = excluded.budget""",
(package, now.isoformat(), today, today, budget),
)
conn.commit()
finally:
conn.close()
def get_trials(self):
"""Return trial rows with computed days_remaining/expired."""
with self._lock:
self._ensure_db()
conn = self._connect()
try:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"SELECT package, enabled_at, last_use_day, last_boot_day, "
"unused_boot_days, budget FROM trial_packages"
).fetchall()
finally:
conn.close()
result = []
for r in rows:
d = dict(r)
d["days_remaining"] = max(0, d["budget"] - d["unused_boot_days"])
d["expired"] = d["unused_boot_days"] >= d["budget"]
result.append(d)
return result
```
**Step 4: Run test to verify it passes**
Run: `python -m pytest tests/test_trials.py -v`
Expected: PASS (2 tests).
**Step 5: Commit**
```bash
git add tracker.py tests/test_trials.py
git commit -m "feat(trials): add trial_packages table, start_trial, get_trials"
```
---
### Task 2: `tick_boot_days` (distinct-day counting)
**Files:**
- Modify: `tracker.py`
- Test: `tests/test_trials.py`
**Step 1: Write the failing test**
```python
def _ahead(days):
return datetime.now(timezone.utc) + timedelta(days=days)
def test_tick_increments_only_on_new_day(tracker):
tracker.start_trial("Pack") # enable day, counter 0
tracker.tick_boot_days() # same day -> no change
assert tracker.get_trials()[0]["unused_boot_days"] == 0
with patch("tracker.datetime") as m:
m.now.return_value = _ahead(1)
tracker.tick_boot_days() # new day -> 1
tracker.tick_boot_days() # same (mocked) day -> still 1
assert tracker.get_trials()[0]["unused_boot_days"] == 1
def test_tick_reaches_expiry(tracker):
tracker.start_trial("Pack")
for d in range(1, DEFAULT_TRIAL_BUDGET + 1):
with patch("tracker.datetime") as m:
m.now.return_value = _ahead(d)
tracker.tick_boot_days()
t = tracker.get_trials()[0]
assert t["unused_boot_days"] == DEFAULT_TRIAL_BUDGET
assert t["expired"] is True
assert t["days_remaining"] == 0
```
**Step 2: Run test to verify it fails**
Run: `python -m pytest tests/test_trials.py -k tick -v`
Expected: FAIL (no `tick_boot_days`).
**Step 3: Write minimal implementation**
```python
def tick_boot_days(self):
"""Once per distinct calendar day, age every active trial by one boot-day."""
today = datetime.now(timezone.utc).date().isoformat()
with self._lock:
self._ensure_db()
conn = self._connect()
try:
conn.execute(
"""UPDATE trial_packages
SET unused_boot_days = unused_boot_days + 1,
last_boot_day = ?
WHERE last_boot_day != ?""",
(today, today),
)
conn.commit()
finally:
conn.close()
```
**Step 4: Run test to verify it passes**
Run: `python -m pytest tests/test_trials.py -k tick -v`
Expected: PASS.
**Step 5: Commit**
```bash
git add tracker.py tests/test_trials.py
git commit -m "feat(trials): tick_boot_days counts distinct boot-days"
```
---
### Task 3: `reset_trials_for` (usage resets counter)
**Files:**
- Modify: `tracker.py`
- Test: `tests/test_trials.py`
**Step 1: Write the failing test**
```python
def test_reset_zeroes_counter(tracker):
tracker.start_trial("Pack")
with patch("tracker.datetime") as m:
m.now.return_value = _ahead(1)
tracker.tick_boot_days()
assert tracker.get_trials()[0]["unused_boot_days"] == 1
tracker.reset_trials_for({"Pack", "Not-On-Trial"})
assert tracker.get_trials()[0]["unused_boot_days"] == 0
def test_reset_empty_is_noop(tracker):
tracker.start_trial("Pack")
tracker.reset_trials_for(set())
assert tracker.get_trials()[0]["unused_boot_days"] == 0
```
**Step 2: Run test to verify it fails**
Run: `python -m pytest tests/test_trials.py -k reset -v`
Expected: FAIL (no `reset_trials_for`).
**Step 3: Write minimal implementation**
```python
def reset_trials_for(self, packages):
"""Reset the unused-day counter for any of these packages that are on trial."""
if not packages:
return
today = datetime.now(timezone.utc).date().isoformat()
with self._lock:
self._ensure_db()
conn = self._connect()
try:
conn.executemany(
"""UPDATE trial_packages
SET unused_boot_days = 0, last_use_day = ?
WHERE package = ?""",
[(today, p) for p in packages],
)
conn.commit()
finally:
conn.close()
```
**Step 4: Run test to verify it passes**
Run: `python -m pytest tests/test_trials.py -k reset -v`
Expected: PASS.
**Step 5: Commit**
```bash
git add tracker.py tests/test_trials.py
git commit -m "feat(trials): reset_trials_for zeroes counter on use"
```
---
### Task 4: `stop_trial` + extend `reset()`
**Files:**
- Modify: `tracker.py` (add `stop_trial`; add `DELETE FROM trial_packages` to `reset`)
- Test: `tests/test_trials.py`
**Step 1: Write the failing test**
```python
def test_stop_trial_removes_row(tracker):
tracker.start_trial("Pack")
tracker.stop_trial("Pack")
assert tracker.get_trials() == []
def test_reset_clears_trials(tracker):
tracker.start_trial("Pack")
tracker.reset()
assert tracker.get_trials() == []
```
**Step 2: Run test to verify it fails**
Run: `python -m pytest tests/test_trials.py -k "stop or clears" -v`
Expected: FAIL (no `stop_trial`; `reset` leaves the row).
**Step 3: Write minimal implementation**
```python
def stop_trial(self, package):
"""End a trial (package became permanent or was disabled)."""
with self._lock:
self._ensure_db()
conn = self._connect()
try:
conn.execute("DELETE FROM trial_packages WHERE package = ?", (package,))
conn.commit()
finally:
conn.close()
```
In `reset()`, add alongside the existing deletes:
```python
conn.execute("DELETE FROM trial_packages")
```
**Step 4: Run test to verify it passes**
Run: `python -m pytest tests/test_trials.py -v`
Expected: PASS (all trial tests).
**Step 5: Commit**
```bash
git add tracker.py tests/test_trials.py
git commit -m "feat(trials): stop_trial and clear trials on reset"
```
---
## Phase 2 — Wire backend into the server
### Task 5: Boot tick, usage-reset hook, and routes
**Files:**
- Modify: `__init__.py`
**Step 1: Boot tick at import.** After `model_mapper = ModelMapper()` and before/around the prompt-handler registration, add:
```python
# Age temporary-enable trials once per process start (one "boot").
try:
tracker.tick_boot_days()
except Exception:
logger.warning("nodes-stats: error ticking trial boot days", exc_info=True)
```
**Step 2: Reset trials on use.** In `_record_prompt`, after `tracker.record_usage(class_types, mapper)` succeeds, add:
```python
try:
packages = {mapper.get_package(ct) for ct in class_types}
packages.discard("__builtin__")
packages.discard("__unknown__")
tracker.reset_trials_for(packages)
except Exception:
logger.warning("nodes-stats: error resetting trials", exc_info=True)
```
**Step 3: Add routes.** After the existing `reset_stats` route:
```python
@routes.get("/nodes-stats/trials")
async def get_trials(request):
try:
return web.json_response(tracker.get_trials())
except Exception:
logger.error("nodes-stats: error getting trials", exc_info=True)
return web.json_response({"error": "internal error"}, status=500)
@routes.post("/nodes-stats/trials/start")
async def start_trial(request):
try:
data = await request.json()
package = data.get("package")
if not package:
return web.json_response({"error": "package required"}, status=400)
tracker.start_trial(package)
return web.json_response({"status": "ok"})
except Exception:
logger.error("nodes-stats: error starting trial", exc_info=True)
return web.json_response({"error": "internal error"}, status=500)
@routes.post("/nodes-stats/trials/stop")
async def stop_trial(request):
try:
data = await request.json()
package = data.get("package")
if not package:
return web.json_response({"error": "package required"}, status=400)
tracker.stop_trial(package)
return web.json_response({"status": "ok"})
except Exception:
logger.error("nodes-stats: error stopping trial", exc_info=True)
return web.json_response({"error": "internal error"}, status=500)
```
**Step 4: Verify import doesn't break.**
Run: `python -c "import ast; ast.parse(open('__init__.py').read()); print('OK')"`
Expected: `OK`. Then `python -m pytest -q` → all green (existing + trial tests).
**Step 5: Commit**
```bash
git add __init__.py
git commit -m "feat(trials): boot tick, usage reset hook, and trial routes"
```
---
## Phase 3 — Verify Manager enable payload (spike)
### Task 6: Empirically confirm the enable payload for a disabled pack
**Why:** the disable bug taught us not to assume Manager's payload shape. Enable goes through `/manager/queue/install` with `skip_post_install=true`.
**Files:** none (investigation; document findings in this plan / a code comment).
**Step 1:** Pick a currently-disabled pack from the live server (port 8189 in this environment; adjust if changed):
Run:
```bash
curl -s "http://127.0.0.1:8189/customnode/getlist?mode=local&skip_update=true" \
| python3 -c "import sys,json; d=json.load(sys.stdin); [print(k, {x:v.get(x) for x in ('id','version','files','state')}) for k,v in d['node_packs'].items() if v.get('state')=='disabled'][:5]"
```
Expected: rows with `state='disabled'` and their `id`/`version`/`files`.
**Step 2:** Enable one via the install queue mirroring Manager's UI, then verify it flips to `enabled`:
```bash
# Use the id/version/files from Step 1 for ONE pack:
curl -s -XPOST "http://127.0.0.1:8189/manager/queue/reset" -H 'Content-Type: application/json'
curl -s -XPOST "http://127.0.0.1:8189/manager/queue/install" -H 'Content-Type: application/json' \
-d '{"id":"<ID>","version":"<VERSION>","files":<FILES_JSON>,"channel":"default","mode":"cache","skip_post_install":true,"selected_version":"<VERSION>","ui_id":"<ID>"}'
curl -s -XPOST "http://127.0.0.1:8189/manager/queue/start" -H 'Content-Type: application/json'
# poll status, then re-check getlist state for that pack == 'enabled'
```
Expected: pack state becomes `enabled` (restart still required to load it).
**Step 3:** Record the exact minimal working payload as a comment to reuse in the frontend (`enablePayload`). Likely `{id, version, files?, channel, mode, skip_post_install:true, selected_version, ui_id}`. Note any field that turned out load-bearing.
**Step 4:** (optional) revert the test enable via the disable endpoint so state is unchanged.
**No commit** (investigation only). Carry findings into Task 9.
---
## Phase 4 — Frontend (manual verification; no JS test harness)
All Phase 4 tasks edit `js/nodes_stats.js`. After each, hard-refresh the browser (Ctrl+Shift+R) and verify in ComfyUI. Sanity-check syntax after each edit:
`cp js/nodes_stats.js /tmp/c.mjs && node --check /tmp/c.mjs && rm /tmp/c.mjs`.
### Task 7: Detect unresolved node types on workflow load
**Step 1:** Add a helper that returns the set of node types in the current graph not registered in LiteGraph:
```js
function unresolvedNodeTypes() {
const types = new Set();
const nodes = app.graph?._nodes || [];
for (const n of nodes) {
const t = n.type;
if (t && !LiteGraph.registered_node_types[t]) types.add(t);
}
return [...types];
}
```
**Step 2:** Hook workflow load by wrapping `app.loadGraphData` in `setup()`:
```js
const origLoad = app.loadGraphData?.bind(app);
if (origLoad) {
app.loadGraphData = function (...args) {
const r = origLoad(...args);
setTimeout(() => onWorkflowLoaded(), 0); // after graph settles
return r;
};
}
```
**Step 3:** Stub `onWorkflowLoaded` to log for now:
```js
async function onWorkflowLoaded() {
const unresolved = unresolvedNodeTypes();
if (unresolved.length) console.log("[Node Stats] unresolved:", unresolved);
}
```
**Verify:** load a workflow containing a disabled pack's node → console lists the type(s). **Commit.**
### Task 8: Classify unresolved types into Disabled vs Missing
**Step 1:** Fetch and build the class_type → pack map and pack states. Add:
```js
async function classifyUnresolved(types) {
if (!types.length) return { disabled: [], missing: [] };
let mappings = {}, managerInfo = null;
try {
const [mResp, gi] = await Promise.all([
fetch("/customnode/getmappings?mode=local"),
fetchManagerInfo(), // existing: getlist -> {dir: {id,version,files,state}}
]);
if (mResp.ok) mappings = await mResp.json();
managerInfo = gi;
} catch { /* manager absent */ }
// class_type -> packKey (getmappings value is [ [class_types...], {meta} ])
const typeToPack = {};
for (const [packKey, entry] of Object.entries(mappings)) {
for (const ct of (entry?.[0] || [])) typeToPack[ct] = packKey;
}
// index managerInfo by id/cnr_id/aux_id as well as dir-name key
const byAnyKey = {};
if (managerInfo) for (const [dir, info] of Object.entries(managerInfo)) {
byAnyKey[dir] = info;
for (const k of [info.id, info.cnr_id, info.aux_id]) if (k) byAnyKey[k] = { ...info, _dir: dir };
}
const disabled = [], missing = [];
for (const ct of types) {
const packKey = typeToPack[ct];
const info = packKey ? byAnyKey[packKey] : null;
if (info && info.state === "disabled") disabled.push({ type: ct, pkg: info._dir || packKey, info });
else missing.push({ type: ct, pkg: packKey || null });
}
return { disabled, missing };
}
```
> Note (from Task 6): confirm `managerInfo` entries actually expose `cnr_id`/`aux_id`; if not, extend `fetchManagerInfo` to keep them so the getmappings key reconciles. `fetchManagerInfo` currently keeps `{id, version, files, state}` — add `cnr_id`/`aux_id` if present in getlist.
**Verify:** in console, call the classifier on `unresolvedNodeTypes()` for a workflow with a disabled pack → it lands in `disabled`. **Commit.**
### Task 9: Workflow tab UI + auto-open
**Step 1:** Add a third tab button `#ns-tab-workflow` next to Nodes/Models in `showStatsDialog`, a `#ns-content-workflow` container, and extend `switchTab` to handle `"workflow"`.
**Step 2:** Build the tab content from a classification result:
```js
function buildWorkflowTabContent({ disabled, missing }, trials) {
const trialByPkg = Object.fromEntries((trials || []).map(t => [t.package, t]));
let html = "";
if (!disabled.length && !missing.length) {
return `<p style="color:#666;">No missing or disabled nodes in the current workflow.</p>`;
}
if (disabled.length) {
html += sectionHeader("Disabled", "Installed but disabled — re-enable to use", "#e90");
html += `<table style="width:100%;border-collapse:collapse;margin-bottom:12px;"><tbody>`;
for (const d of disabled) {
const t = trialByPkg[d.pkg];
const note = t ? `<span style="color:#6a6;font-size:11px;">on trial · ${t.days_remaining}d left</span>` : "";
html += `<tr class="ns-row-consider_removing" style="border-bottom:1px solid #222;">
<td style="padding:6px 8px;color:#fff;">${escapeHtml(d.type)}</td>
<td style="padding:6px 8px;color:#888;">${escapeHtml(d.pkg)} ${note}</td>
<td style="padding:6px 8px;text-align:right;white-space:nowrap;">
<button class="ns-btn ns-enable-temp-btn" data-pkg="${escapeAttr(d.pkg)}">Enable 7d</button>
<button class="ns-btn ns-enable-perm-btn" data-pkg="${escapeAttr(d.pkg)}" style="margin-left:6px;">Enable</button>
</td></tr>`;
}
html += `</tbody></table>`;
}
if (missing.length) {
html += sectionHeader("Missing", "Not installed — install via ComfyUI Manager", "#e44");
html += `<table style="width:100%;border-collapse:collapse;margin-bottom:12px;"><tbody>`;
for (const m of missing) {
html += `<tr class="ns-row-safe_to_remove" style="border-bottom:1px solid #222;">
<td style="padding:6px 8px;color:#fff;">${escapeHtml(m.type)}</td>
<td style="padding:6px 8px;color:#888;">${m.pkg ? escapeHtml(m.pkg) : "unknown"}</td>
<td style="padding:6px 8px;text-align:right;">
${m.pkg ? `<button class="ns-btn ns-install-btn" data-pkg="${escapeAttr(m.pkg)}">Install</button>` : "&mdash;"}
</td></tr>`;
}
html += `</tbody></table>`;
}
return html;
}
```
**Step 3:** Store the latest classification in module scope so `showStatsDialog` can render the tab, and have `onWorkflowLoaded` open the dialog on the Workflow tab when there's ≥1 item:
```js
let _lastWorkflowScan = { disabled: [], missing: [] };
async function onWorkflowLoaded() {
const types = unresolvedNodeTypes();
_lastWorkflowScan = await classifyUnresolved(types);
if (_lastWorkflowScan.disabled.length || _lastWorkflowScan.missing.length) {
showStatsDialog("workflow"); // showStatsDialog gains an optional initial-tab arg
}
}
```
**Step 4:** Give `showStatsDialog(initialTab = "nodes")` an optional arg; after building, call `switchTab(initialTab)`; render `buildWorkflowTabContent(_lastWorkflowScan, trials)` where `trials = await fetch('/nodes-stats/trials')`.
**Verify:** load a workflow with a disabled node → dialog auto-opens to Workflow tab listing it. **Commit.**
### Task 10: Enable temporary / permanent actions
**Step 1:** Add `enablePayload(dirName, info)` using the shape confirmed in Task 6, e.g.:
```js
function enablePayload(dirName, info) {
return {
id: info.id || dirName, version: info.version, files: info.files,
channel: "default", mode: "cache", skip_post_install: true,
selected_version: info.version, ui_id: dirName,
};
}
```
**Step 2:** Add `runManagerEnable(payload)` mirroring `runManagerDisable` but POSTing to `/manager/queue/install`, then `start`, then `waitForQueue()`.
**Step 3:** Wire the buttons (after the dialog is built, alongside `wireDisableButtons`):
```js
dialog.querySelectorAll(".ns-enable-temp-btn").forEach(b =>
b.addEventListener("click", e => { e.stopPropagation(); handleEnable(b.dataset.pkg, true, dialog); }));
dialog.querySelectorAll(".ns-enable-perm-btn").forEach(b =>
b.addEventListener("click", e => { e.stopPropagation(); handleEnable(b.dataset.pkg, false, dialog); }));
```
**Step 4:** Implement `handleEnable`:
```js
async function handleEnable(pkg, temporary, dialog) {
const info = (_lastWorkflowScan.disabled.find(d => d.pkg === pkg) || {}).info;
if (!info) return;
setDisableButtonsBusy(dialog, true);
try {
await runManagerEnable(enablePayload(pkg, info));
if (temporary) await fetch("/nodes-stats/trials/start", { method: "POST",
headers: { "Content-Type": "application/json" }, body: JSON.stringify({ package: pkg }) });
else await fetch("/nodes-stats/trials/stop", { method: "POST",
headers: { "Content-Type": "application/json" }, body: JSON.stringify({ package: pkg }) });
showRestartBanner(dialog);
notify(`Enabled ${pkg}${temporary ? " for a 7-day trial" : ""}. Restart ComfyUI to apply.`, "success");
} catch (e) {
notify("Failed to enable: " + e.message, "error");
} finally {
setDisableButtonsBusy(dialog, false);
}
}
```
**Verify:** click Enable 7d on a disabled node → pack flips to enabled in getlist, `/nodes-stats/trials` shows it, restart banner appears. Click Enable (permanent) on another → enabled, no trial row. **Commit.**
### Task 11: Missing → install via Manager
**Step 1:** Wire `.ns-install-btn` to install the owning pack via Manager. Resolve the pack's getlist entry (it will be `state:'not-installed'` — fetch a fresh getlist or reuse classification info), then POST `/manager/queue/install` with `selected_version:'latest'`, `skip_post_install:false`, start, wait, restart banner.
```js
async function handleInstall(pkg, dialog) {
// fetch the not-installed entry's id/version/files from getlist by pkg key
// POST /manager/queue/install {id, version, files, channel:'default', mode:'cache',
// selected_version:'latest', ui_id:pkg}; then start + waitForQueue + restart banner
}
```
> Keep this minimal — "handled by Manager like always." If resolving the install entry proves fiddly, fall back to a button that opens ComfyUI Manager's own Install-Missing UI instead of replicating install. Decide during implementation; document the choice.
**Verify:** click Install on a missing node → Manager installs it (or opens its installer). **Commit.**
### Task 12: Execute expiry on load
**Step 1:** Add `processExpiredTrials()` that fetches `/nodes-stats/trials`, and for each `expired` pack: build a disable payload (reuse `disablePayload` with a fresh `fetchManagerInfo` lookup), `runManagerDisable`, then POST `/nodes-stats/trials/stop`. Collect successes for a single toast.
```js
async function processExpiredTrials() {
let trials = [];
try { const r = await fetch("/nodes-stats/trials"); if (r.ok) trials = await r.json(); } catch { return; }
const expired = trials.filter(t => t.expired);
if (!expired.length) return;
const mgr = await fetchManagerInfo();
if (!mgr) return;
const done = [];
for (const t of expired) {
const info = mgr[t.package];
if (!info || info.state === "disabled") { await stopTrial(t.package); done.push(t.package); continue; }
try {
await runManagerDisable([disablePayload(t.package, info)]);
await stopTrial(t.package);
done.push(t.package);
} catch { /* keep row for next session */ }
}
if (done.length) notify(`Auto-disabled ${done.length} unused trial package(s). Restart ComfyUI to apply.`, "info");
}
```
**Step 2:** Call `processExpiredTrials()` once from `setup()` (after a short delay so the app is ready), guarded so it runs only when Manager is present.
**Verify:** with a trial row forced to `unused_boot_days >= budget` (set via DB or repeated tick), reloading ComfyUI auto-disables that pack and clears the trial. **Commit.**
---
## Phase 5 — Docs & version
### Task 13: README + version bump
**Files:** `README.md`, `pyproject.toml`
**Step 1:** Add a "Workflow tab & temporary enable" subsection to the README (what Missing vs Disabled mean, the 7 distinct-boot-day rolling trial, that use resets it, that auto-disable applies on next UI load and needs a restart). Add a feature bullet. Document the three `/nodes-stats/trials*` endpoints in the API table.
**Step 2:** Bump `version` in `pyproject.toml` to `1.3.0`.
**Step 3:** Run `python -m pytest -q` (all green) and JS `node --check`.
**Step 4: Commit**
```bash
git add README.md pyproject.toml
git commit -m "docs: document workflow tab + trial-enable; bump to 1.3.0"
```
---
## Done criteria
- `python -m pytest -q` green (existing + `tests/test_trials.py`).
- Loading a workflow with a disabled node auto-opens the Workflow tab; Enable 7d re-enables + records a trial; Enable makes it permanent.
- A trial package unused for 7 distinct boot-days auto-disables on next UI load; any execution use resets the counter.
- Missing nodes route to ComfyUI Manager.
- Feature is inert when ComfyUI Manager is absent.
+334 -16
View File
@@ -44,22 +44,61 @@ app.registerExtension({
menu.append(btn); menu.append(btn);
} }
} }
// Detect missing/disabled nodes whenever a workflow is loaded.
const origLoad = app.loadGraphData?.bind(app);
if (origLoad) {
app.loadGraphData = function (...args) {
const r = origLoad(...args);
setTimeout(() => onWorkflowLoaded(), 0); // after graph settles
return r;
};
}
// Once the app has settled, auto-disable trial packages that went unused for
// their full budget of distinct boot-days. Inert when ComfyUI Manager is absent.
setTimeout(() => { processExpiredTrials().catch(() => {}); }, 3000);
}, },
}); });
async function showStatsDialog() { // Return the set of node types present in the current graph that LiteGraph
let data, modelData, managerInfo; // doesn't have registered — i.e. nodes from missing or disabled packages.
function unresolvedNodeTypes() {
const types = new Set();
const nodes = app.graph?._nodes || [];
for (const n of nodes) {
const t = n.type;
if (t && !LiteGraph.registered_node_types[t]) types.add(t);
}
return [...types];
}
// Latest workflow scan, shared so showStatsDialog can render the Workflow tab.
let _lastWorkflowScan = { disabled: [], missing: [] };
async function onWorkflowLoaded() {
const types = unresolvedNodeTypes();
_lastWorkflowScan = await classifyUnresolved(types);
if (_lastWorkflowScan.disabled.length || _lastWorkflowScan.missing.length) {
showStatsDialog("workflow"); // auto-open on the Workflow tab
}
}
async function showStatsDialog(initialTab = "nodes") {
let data, modelData, managerInfo, trials = [];
try { try {
const [pkgResp, modelResp, mgr] = await Promise.all([ const [pkgResp, modelResp, mgr, trialsResp] = await Promise.all([
fetch("/nodes-stats/packages"), fetch("/nodes-stats/packages"),
fetch("/nodes-stats/models"), fetch("/nodes-stats/models"),
fetchManagerInfo(), fetchManagerInfo(),
fetch("/nodes-stats/trials").catch(() => null),
]); ]);
if (!pkgResp.ok) { alert("Failed to load node stats: HTTP " + pkgResp.status); return; } if (!pkgResp.ok) { alert("Failed to load node stats: HTTP " + pkgResp.status); return; }
if (!modelResp.ok) { alert("Failed to load model stats: HTTP " + modelResp.status); return; } if (!modelResp.ok) { alert("Failed to load model stats: HTTP " + modelResp.status); return; }
data = await pkgResp.json(); data = await pkgResp.json();
modelData = await modelResp.json(); modelData = await modelResp.json();
managerInfo = mgr; managerInfo = mgr;
if (trialsResp && trialsResp.ok) { try { trials = await trialsResp.json(); } catch { trials = []; } }
if (!Array.isArray(data) || !Array.isArray(modelData)) { if (!Array.isArray(data) || !Array.isArray(modelData)) {
alert("Failed to load stats: unexpected response format"); alert("Failed to load stats: unexpected response format");
return; return;
@@ -105,6 +144,10 @@ async function showStatsDialog() {
style="background:none;border:none;border-bottom:2px solid transparent;color:#888;padding:8px 18px;cursor:pointer;font-family:monospace;font-size:13px;"> style="background:none;border:none;border-bottom:2px solid transparent;color:#888;padding:8px 18px;cursor:pointer;font-family:monospace;font-size:13px;">
Models Models
</button> </button>
<button id="ns-tab-workflow"
style="background:none;border:none;border-bottom:2px solid transparent;color:#888;padding:8px 18px;cursor:pointer;font-family:monospace;font-size:13px;">
Workflow
</button>
</div>`; </div>`;
// Nodes tab content // Nodes tab content
@@ -117,25 +160,29 @@ async function showStatsDialog() {
html += buildModelsTabContent(modelData); html += buildModelsTabContent(modelData);
html += `</div>`; html += `</div>`;
// Workflow tab content (missing / disabled nodes in the loaded workflow)
html += `<div id="ns-content-workflow" style="display:none;">`;
html += buildWorkflowTabContent(_lastWorkflowScan, trials);
html += `</div>`;
dialog.innerHTML = html; dialog.innerHTML = html;
overlay.appendChild(dialog); overlay.appendChild(dialog);
document.body.appendChild(overlay); document.body.appendChild(overlay);
// Tab switch — local function, no window pollution // Tab switch — local function, no window pollution
const TABS = ["nodes", "models", "workflow"];
function switchTab(tab) { function switchTab(tab) {
dialog.querySelector("#ns-content-nodes").style.display = tab === "nodes" ? "" : "none"; for (const t of TABS) {
dialog.querySelector("#ns-content-models").style.display = tab === "models" ? "" : "none"; dialog.querySelector(`#ns-content-${t}`).style.display = t === tab ? "" : "none";
const nodeBtn = dialog.querySelector("#ns-tab-nodes"); const b = dialog.querySelector(`#ns-tab-${t}`);
const modelBtn = dialog.querySelector("#ns-tab-models"); b.style.borderBottomColor = t === tab ? "#4a4" : "transparent";
nodeBtn.style.borderBottomColor = tab === "nodes" ? "#4a4" : "transparent"; b.style.color = t === tab ? "#4a4" : "#888";
nodeBtn.style.color = tab === "nodes" ? "#4a4" : "#888"; b.style.fontWeight = t === tab ? "bold" : "normal";
nodeBtn.style.fontWeight = tab === "nodes" ? "bold" : "normal"; }
modelBtn.style.borderBottomColor = tab === "models" ? "#4a4" : "transparent"; }
modelBtn.style.color = tab === "models" ? "#4a4" : "#888"; for (const t of TABS) {
modelBtn.style.fontWeight = tab === "models" ? "bold" : "normal"; dialog.querySelector(`#ns-tab-${t}`).addEventListener("click", () => switchTab(t));
} }
dialog.querySelector("#ns-tab-nodes").addEventListener("click", () => switchTab("nodes"));
dialog.querySelector("#ns-tab-models").addEventListener("click", () => switchTab("models"));
dialog.querySelector("#nodes-stats-close").addEventListener("click", () => overlay.remove()); dialog.querySelector("#nodes-stats-close").addEventListener("click", () => overlay.remove());
@@ -154,6 +201,9 @@ async function showStatsDialog() {
}); });
wireDisableButtons(dialog, managerInfo); wireDisableButtons(dialog, managerInfo);
wireWorkflowButtons(dialog);
switchTab(TABS.includes(initialTab) ? initialTab : "nodes");
// Easter egg: click "used" badge 5 times to show podium // Easter egg: click "used" badge 5 times to show podium
let eggClicks = 0; let eggClicks = 0;
@@ -315,6 +365,47 @@ function buildModelTable(models) {
return html; return html;
} }
// Render the Workflow tab from a classification result. `disabled` entries get
// re-enable actions (temporary trial or permanent); `missing` entries get an
// Install button that defers to ComfyUI Manager.
function buildWorkflowTabContent({ disabled, missing }, trials) {
const trialByPkg = Object.fromEntries((trials || []).map((t) => [t.package, t]));
let html = "";
if (!disabled.length && !missing.length) {
return `<p style="color:#666;">No missing or disabled nodes in the current workflow.</p>`;
}
if (disabled.length) {
html += sectionHeader("Disabled", "Installed but disabled — re-enable to use", "#e90");
html += `<table style="width:100%;border-collapse:collapse;margin-bottom:12px;"><tbody>`;
for (const d of disabled) {
const t = trialByPkg[d.pkg];
const note = t ? `<span style="color:#6a6;font-size:11px;">on trial · ${t.days_remaining}d left</span>` : "";
html += `<tr class="ns-row-consider_removing" style="border-bottom:1px solid #222;">
<td style="padding:6px 8px;color:#fff;">${escapeHtml(d.type)}</td>
<td style="padding:6px 8px;color:#888;">${escapeHtml(d.pkg)} ${note}</td>
<td style="padding:6px 8px;text-align:right;white-space:nowrap;">
<button class="ns-btn ns-enable-temp-btn" data-pkg="${escapeAttr(d.pkg)}">Enable 7d</button>
<button class="ns-btn ns-enable-perm-btn" data-pkg="${escapeAttr(d.pkg)}" style="margin-left:6px;">Enable</button>
</td></tr>`;
}
html += `</tbody></table>`;
}
if (missing.length) {
html += sectionHeader("Missing", "Not installed — install via ComfyUI Manager", "#e44");
html += `<table style="width:100%;border-collapse:collapse;margin-bottom:12px;"><tbody>`;
for (const m of missing) {
html += `<tr class="ns-row-safe_to_remove" style="border-bottom:1px solid #222;">
<td style="padding:6px 8px;color:#fff;">${escapeHtml(m.type)}</td>
<td style="padding:6px 8px;color:#888;">${m.pkg ? escapeHtml(m.pkg) : "unknown"}</td>
<td style="padding:6px 8px;text-align:right;">
${m.pkg ? `<button class="ns-btn ns-install-btn" data-pkg="${escapeAttr(m.pkg)}">Install</button>` : "&mdash;"}
</td></tr>`;
}
html += `</tbody></table>`;
}
return html;
}
function sectionHeader(title, subtitle, color) { function sectionHeader(title, subtitle, color) {
let html = `<h3 style="color:${color};margin:16px 0 8px;font-size:14px;">${escapeHtml(title)}`; let html = `<h3 style="color:${color};margin:16px 0 8px;font-size:14px;">${escapeHtml(title)}`;
if (subtitle) html += ` <span style="color:#666;font-size:12px;font-weight:normal;">— ${escapeHtml(subtitle)}</span>`; if (subtitle) html += ` <span style="color:#666;font-size:12px;font-weight:normal;">— ${escapeHtml(subtitle)}</span>`;
@@ -400,7 +491,12 @@ async function fetchManagerInfo() {
for (const [key, v] of Object.entries(packs)) { for (const [key, v] of Object.entries(packs)) {
if (!v || v.state === "not-installed") continue; if (!v || v.state === "not-installed") continue;
// For installed packs the key is the directory name — matches our package names. // For installed packs the key is the directory name — matches our package names.
info[key] = { id: v.id || key, version: v.version, files: v.files, state: v.state }; // cnr_id/aux_id are kept so getmappings keys (which may be a registry id or
// repo URL rather than the dir name) can be reconciled in classifyUnresolved.
info[key] = {
id: v.id || key, version: v.version, files: v.files, state: v.state,
cnr_id: v.cnr_id, aux_id: v.aux_id,
};
} }
return info; return info;
} catch { } catch {
@@ -408,6 +504,50 @@ async function fetchManagerInfo() {
} }
} }
// Split unresolved node types into packages that are installed-but-disabled
// (re-enable to use) vs not installed (install via Manager). Reconciles
// ComfyUI Manager's getmappings (class_type -> pack key) against getlist state.
async function classifyUnresolved(types) {
if (!types.length) return { disabled: [], missing: [] };
let mappings = {}, managerInfo = null;
try {
const [mResp, gi] = await Promise.all([
fetch("/customnode/getmappings?mode=local"),
fetchManagerInfo(), // getlist -> {dir: {id, cnr_id, aux_id, version, files, state}}
]);
if (mResp.ok) mappings = await mResp.json();
managerInfo = gi;
} catch { /* manager absent */ }
// class_type -> packKey. getmappings value is [ [class_types...], {meta} ];
// packKey is a directory name OR a repo/gist URL depending on the pack.
const typeToPack = {};
for (const [packKey, entry] of Object.entries(mappings)) {
for (const ct of (entry?.[0] || [])) typeToPack[ct] = packKey;
}
// Index installed/disabled packs by every identifier they expose (dir name,
// id, cnr_id, aux_id, and each repo URL) so a getmappings key in any of those
// forms resolves. URLs are normalized (drop trailing slash / .git, lowercase).
const norm = (s) => String(s).trim().replace(/\/+$/, "").replace(/\.git$/i, "").toLowerCase();
const byAnyKey = {};
if (managerInfo) for (const [dir, info] of Object.entries(managerInfo)) {
const rec = { ...info, _dir: dir };
byAnyKey[norm(dir)] = rec;
for (const k of [info.id, info.cnr_id, info.aux_id]) if (k) byAnyKey[norm(k)] = rec;
for (const f of (info.files || [])) if (f) byAnyKey[norm(f)] = rec;
}
const disabled = [], missing = [];
for (const ct of types) {
const packKey = typeToPack[ct];
const info = packKey ? byAnyKey[norm(packKey)] : null;
if (info && info.state === "disabled") disabled.push({ type: ct, pkg: info._dir, info });
else missing.push({ type: ct, pkg: packKey || null });
}
return { disabled, missing };
}
// Build the payload ComfyUI Manager's /manager/queue/disable expects, mirroring // Build the payload ComfyUI Manager's /manager/queue/disable expects, mirroring
// Manager's own frontend: id = directory name, version = install state // Manager's own frontend: id = directory name, version = install state
// ("nightly" / semver / "unknown"), and files (repo URL) only for "unknown". // ("nightly" / semver / "unknown"), and files (repo URL) only for "unknown".
@@ -439,6 +579,17 @@ function wireDisableButtons(dialog, managerInfo) {
}); });
} }
// Wire the Workflow tab's enable/install buttons. Handlers are filled in by the
// enable (Task 10) and install (Task 11) steps.
function wireWorkflowButtons(dialog) {
dialog.querySelectorAll(".ns-enable-temp-btn").forEach((b) =>
b.addEventListener("click", (e) => { e.stopPropagation(); handleEnable(b.dataset.pkg, true, dialog); }));
dialog.querySelectorAll(".ns-enable-perm-btn").forEach((b) =>
b.addEventListener("click", (e) => { e.stopPropagation(); handleEnable(b.dataset.pkg, false, dialog); }));
dialog.querySelectorAll(".ns-install-btn").forEach((b) =>
b.addEventListener("click", (e) => { e.stopPropagation(); handleInstall(b.dataset.pkg, dialog); }));
}
async function handleDisable(pkgNames, dialog, managerInfo) { async function handleDisable(pkgNames, dialog, managerInfo) {
// Only act on packages Manager still reports as active (guards against // Only act on packages Manager still reports as active (guards against
// double-clicks and stale buttons after a partial batch). // double-clicks and stale buttons after a partial batch).
@@ -509,6 +660,173 @@ async function runManagerDisable(payloads) {
await waitForQueue(); await waitForQueue();
} }
// Re-enable a disabled pack via ComfyUI Manager (confirmed against the live
// server and ComfyUI-Manager's manager_server.py / manager_core.py). Two routes
// through /manager/queue/install, both ending in unified_enable (a dir move out
// of .disabled — never a re-clone):
// • version != "unknown" (nightly/semver): skip_post_install takes the fast
// path, unified_enable(id) is called and the route returns before reading
// channel/mode/files. Load-bearing: id, version, skip_post_install.
// • version == "unknown": queues an install task; install_by_id sees the pack
// is_disabled and calls unified_enable. Needs files (repo URL), channel, mode.
// selected_version always mirrors version, so the "invalid request" arm (version
// set but selected_version=="unknown") is never hit. One payload covers both.
function enablePayload(dirName, info) {
return {
id: info.id || dirName,
version: info.version,
files: info.files,
channel: "default",
mode: "cache",
skip_post_install: true,
selected_version: info.version,
ui_id: dirName,
};
}
// Whether ComfyUI Manager is mid-operation. Used to avoid resetting its queue
// out from under an in-progress install/disable (the manual disable flow guards
// the same way before calling runManagerDisable).
async function managerIsBusy() {
try {
const r = await fetch("/manager/queue/status");
if (!r.ok) return false;
const st = await r.json();
return !!(st && st.is_processing);
} catch {
return false;
}
}
async function runManagerEnable(payload) {
await fetch("/manager/queue/reset", { method: "POST", headers: { "Content-Type": "application/json" } });
const r = await fetch("/manager/queue/install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!r.ok) throw new Error(`enable request failed (HTTP ${r.status})`);
const start = await fetch("/manager/queue/start", { method: "POST", headers: { "Content-Type": "application/json" } });
if (!start.ok && start.status !== 201) throw new Error(`queue start failed (HTTP ${start.status})`);
await waitForQueue();
}
// Enable a disabled package, optionally under a temporary trial. A permanent
// enable clears any existing trial row so the package is never auto-disabled.
async function handleEnable(pkg, temporary, dialog) {
const entry = _lastWorkflowScan.disabled.find((d) => d.pkg === pkg);
const info = entry && entry.info;
if (!info) return;
if (await managerIsBusy()) {
notify("ComfyUI Manager is busy. Please try again in a moment.", "warn");
return;
}
setWorkflowButtonsBusy(dialog, true);
try {
await runManagerEnable(enablePayload(pkg, info));
const route = temporary ? "/nodes-stats/trials/start" : "/nodes-stats/trials/stop";
await fetch(route, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ package: pkg }),
});
if (entry.info) entry.info.state = "enabled";
showRestartBanner(dialog);
notify(`Enabled ${pkg}${temporary ? " for a 7-day trial" : ""}. Restart ComfyUI to apply.`, "success");
} catch (e) {
notify("Failed to enable: " + e.message, "error");
} finally {
setWorkflowButtonsBusy(dialog, false);
}
}
// Missing packages are deferred to ComfyUI Manager — the design treats "Missing"
// as handled by Manager like always, and Manager already surfaces missing nodes
// on workflow load. We intentionally do NOT replicate install: a not-installed
// pack's exact spec can't be resolved reliably client-side (mode=local getlist
// exposes no cnr_id and an ambiguous version field, so cnr@latest vs git@unknown
// can't be chosen without risking "cannot resolve install target"). Instead open
// Manager's Custom Nodes Manager (which has a built-in Missing filter); if that
// command isn't available in this ComfyUI build, guide the user to it.
async function handleInstall(pkg, dialog) {
let opened = false;
try {
const cmd = app?.extensionManager?.command;
if (cmd && typeof cmd.execute === "function") {
await cmd.execute("Comfy.Manager.CustomNodesManager.ToggleVisibility");
opened = true;
}
} catch { /* fall through to guidance */ }
notify(
opened
? `Opened ComfyUI Manager — choose the "Missing" filter to install ${pkg}.`
: `Install ${pkg} via ComfyUI Manager → "Install Missing Custom Nodes".`,
"info"
);
}
function setWorkflowButtonsBusy(dialog, busy) {
dialog.querySelectorAll(".ns-enable-temp-btn, .ns-enable-perm-btn, .ns-install-btn").forEach((b) => {
b.disabled = busy;
});
}
async function stopTrial(pkg) {
try {
await fetch("/nodes-stats/trials/stop", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ package: pkg }),
});
} catch { /* best-effort; row ages out next session */ }
}
// On UI load, disable any trial package whose 7 distinct boot-days elapsed with
// no use (the backend marks it expired). The disable goes through ComfyUI
// Manager exactly like a manual disable; the trial row is then cleared. Inert
// when Manager is absent. A package already disabled on disk just clears its row.
async function processExpiredTrials() {
let trials = [];
try {
const r = await fetch("/nodes-stats/trials");
if (r.ok) trials = await r.json();
} catch { return; }
const expired = trials.filter((t) => t.expired);
if (!expired.length) return;
const mgr = await fetchManagerInfo();
if (!mgr) return; // Manager unavailable — leave rows for a later session
// Don't reset Manager's queue out from under an in-progress operation
// (e.g. startup install work); the expired rows persist and retry next session.
if (await managerIsBusy()) return;
const done = [];
for (const t of expired) {
const info = mgr[t.package];
if (!info || info.state === "disabled") {
await stopTrial(t.package);
done.push(t.package);
continue;
}
try {
await runManagerDisable([disablePayload(t.package, info)]);
await stopTrial(t.package);
done.push(t.package);
} catch { /* keep the row; retry next session */ }
}
if (done.length) {
notify(`Auto-disabled ${done.length} unused trial package(s). Restart ComfyUI to apply.`, "info");
}
}
async function waitForQueue(timeoutMs = 60000) { async function waitForQueue(timeoutMs = 60000) {
const deadline = Date.now() + timeoutMs; const deadline = Date.now() + timeoutMs;
await sleep(300); await sleep(300);
+1 -1
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "comfyui-nodes-stats" name = "comfyui-nodes-stats"
description = "Track usage statistics for all ComfyUI nodes and packages" description = "Track usage statistics for all ComfyUI nodes and packages"
version = "1.2.1" version = "1.3.0"
license = "MIT" license = "MIT"
[project.urls] [project.urls]
+83
View File
@@ -0,0 +1,83 @@
import pytest
from datetime import datetime, timezone, timedelta
from unittest.mock import patch
from tracker import UsageTracker, DEFAULT_TRIAL_BUDGET
@pytest.fixture
def tracker(tmp_path):
return UsageTracker(db_path=str(tmp_path / "test.db"))
def test_start_trial_initializes(tracker):
tracker.start_trial("Some-Pack")
trials = tracker.get_trials()
assert len(trials) == 1
t = trials[0]
assert t["package"] == "Some-Pack"
assert t["unused_boot_days"] == 0
assert t["budget"] == DEFAULT_TRIAL_BUDGET
assert t["days_remaining"] == DEFAULT_TRIAL_BUDGET
assert t["expired"] is False
def test_start_trial_is_idempotent_resets(tracker):
tracker.start_trial("Some-Pack")
tracker.start_trial("Some-Pack")
assert len(tracker.get_trials()) == 1
def _ahead(days):
return datetime.now(timezone.utc) + timedelta(days=days)
def test_tick_increments_only_on_new_day(tracker):
tracker.start_trial("Pack") # enable day, counter 0
tracker.tick_boot_days() # same day -> no change
assert tracker.get_trials()[0]["unused_boot_days"] == 0
with patch("tracker.datetime") as m:
m.now.return_value = _ahead(1)
tracker.tick_boot_days() # new day -> 1
tracker.tick_boot_days() # same (mocked) day -> still 1
assert tracker.get_trials()[0]["unused_boot_days"] == 1
def test_tick_reaches_expiry(tracker):
tracker.start_trial("Pack")
for d in range(1, DEFAULT_TRIAL_BUDGET + 1):
with patch("tracker.datetime") as m:
m.now.return_value = _ahead(d)
tracker.tick_boot_days()
t = tracker.get_trials()[0]
assert t["unused_boot_days"] == DEFAULT_TRIAL_BUDGET
assert t["expired"] is True
assert t["days_remaining"] == 0
def test_reset_zeroes_counter(tracker):
tracker.start_trial("Pack")
with patch("tracker.datetime") as m:
m.now.return_value = _ahead(1)
tracker.tick_boot_days()
assert tracker.get_trials()[0]["unused_boot_days"] == 1
tracker.reset_trials_for({"Pack", "Not-On-Trial"})
assert tracker.get_trials()[0]["unused_boot_days"] == 0
def test_reset_empty_is_noop(tracker):
tracker.start_trial("Pack")
tracker.reset_trials_for(set())
assert tracker.get_trials()[0]["unused_boot_days"] == 0
def test_stop_trial_removes_row(tracker):
tracker.start_trial("Pack")
tracker.stop_trial("Pack")
assert tracker.get_trials() == []
def test_reset_clears_trials(tracker):
tracker.start_trial("Pack")
tracker.reset()
assert tracker.get_trials() == []
+106
View File
@@ -48,6 +48,15 @@ CREATE TABLE IF NOT EXISTS model_usage (
last_seen TEXT NOT NULL last_seen TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS trial_packages (
package TEXT PRIMARY KEY,
enabled_at TEXT NOT NULL,
last_use_day TEXT NOT NULL,
last_boot_day TEXT NOT NULL,
unused_boot_days INTEGER NOT NULL DEFAULT 0,
budget INTEGER NOT NULL DEFAULT 7
);
CREATE INDEX IF NOT EXISTS idx_node_usage_package ON node_usage(package); CREATE INDEX IF NOT EXISTS idx_node_usage_package ON node_usage(package);
CREATE INDEX IF NOT EXISTS idx_prompt_log_timestamp ON prompt_log(timestamp); CREATE INDEX IF NOT EXISTS idx_prompt_log_timestamp ON prompt_log(timestamp);
CREATE INDEX IF NOT EXISTS idx_model_usage_type ON model_usage(model_type); CREATE INDEX IF NOT EXISTS idx_model_usage_type ON model_usage(model_type);
@@ -62,6 +71,9 @@ EXCLUDED_PACKAGES = {
} }
DEFAULT_TRIAL_BUDGET = 7
def _classify_age(timestamp, one_month_ago, two_months_ago, recent_status): def _classify_age(timestamp, one_month_ago, two_months_ago, recent_status):
"""Classify an ISO timestamp into a removal tier. """Classify an ISO timestamp into a removal tier.
@@ -375,6 +387,99 @@ class UsageTracker:
finally: finally:
conn.close() conn.close()
def start_trial(self, package, budget=DEFAULT_TRIAL_BUDGET):
"""Begin/restart a temporary-enable trial. The enable day is not counted."""
now = datetime.now(timezone.utc)
today = now.date().isoformat()
with self._lock:
self._ensure_db()
conn = self._connect()
try:
conn.execute(
"""INSERT INTO trial_packages
(package, enabled_at, last_use_day, last_boot_day, unused_boot_days, budget)
VALUES (?, ?, ?, ?, 0, ?)
ON CONFLICT(package) DO UPDATE SET
enabled_at = excluded.enabled_at,
last_use_day = excluded.last_use_day,
last_boot_day = excluded.last_boot_day,
unused_boot_days = 0,
budget = excluded.budget""",
(package, now.isoformat(), today, today, budget),
)
conn.commit()
finally:
conn.close()
def get_trials(self):
"""Return trial rows with computed days_remaining/expired."""
with self._lock:
self._ensure_db()
conn = self._connect()
try:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"SELECT package, enabled_at, last_use_day, last_boot_day, "
"unused_boot_days, budget FROM trial_packages"
).fetchall()
finally:
conn.close()
result = []
for r in rows:
d = dict(r)
d["days_remaining"] = max(0, d["budget"] - d["unused_boot_days"])
d["expired"] = d["unused_boot_days"] >= d["budget"]
result.append(d)
return result
def tick_boot_days(self):
"""Once per distinct calendar day, age every active trial by one boot-day."""
today = datetime.now(timezone.utc).date().isoformat()
with self._lock:
self._ensure_db()
conn = self._connect()
try:
conn.execute(
"""UPDATE trial_packages
SET unused_boot_days = unused_boot_days + 1,
last_boot_day = ?
WHERE last_boot_day != ?""",
(today, today),
)
conn.commit()
finally:
conn.close()
def reset_trials_for(self, packages):
"""Reset the unused-day counter for any of these packages that are on trial."""
if not packages:
return
today = datetime.now(timezone.utc).date().isoformat()
with self._lock:
self._ensure_db()
conn = self._connect()
try:
conn.executemany(
"""UPDATE trial_packages
SET unused_boot_days = 0, last_use_day = ?
WHERE package = ?""",
[(today, p) for p in packages],
)
conn.commit()
finally:
conn.close()
def stop_trial(self, package):
"""End a trial (package became permanent or was disabled)."""
with self._lock:
self._ensure_db()
conn = self._connect()
try:
conn.execute("DELETE FROM trial_packages WHERE package = ?", (package,))
conn.commit()
finally:
conn.close()
def reset(self): def reset(self):
"""Clear all tracked data.""" """Clear all tracked data."""
with self._lock: with self._lock:
@@ -384,6 +489,7 @@ class UsageTracker:
conn.execute("DELETE FROM node_usage") conn.execute("DELETE FROM node_usage")
conn.execute("DELETE FROM prompt_log") conn.execute("DELETE FROM prompt_log")
conn.execute("DELETE FROM model_usage") conn.execute("DELETE FROM model_usage")
conn.execute("DELETE FROM trial_packages")
conn.commit() conn.commit()
finally: finally:
conn.close() conn.close()