Files
Comfyui-Nodes-Stats/docs/plans/2026-04-08-model-tracking.md
Ethanfel b852c37786 Fix 3 bugs in model tracking plan
- Add conftest.py task to stub ComfyUI modules for tests
- Move extract_models_from_prompt into background thread
- Replace window.nsShowTab with local switchTab + addEventListener

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 17:43:08 +02:00

1003 lines
33 KiB
Markdown

# Model Usage Tracking — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Track which model files ComfyUI uses in prompts and surface never-used models so the user can delete them.
**Architecture:** Extend the existing SQLite DB with a `model_usage` table. At prompt time, inspect each node's `INPUT_TYPES()` to find folder-dropdown inputs, look up the selected filename in a `folder_paths` reverse map, and record usage. A new API endpoint returns per-type grouped stats. The existing dialog gets two tabs: Nodes (unchanged) and Models (new).
**Tech Stack:** Python, SQLite (via `sqlite3`), ComfyUI's `folder_paths` + `nodes` modules, vanilla JS.
---
### Task 0: Create test infrastructure
**Files:**
- Create: `tests/conftest.py`
`folder_paths` and `nodes` are ComfyUI modules unavailable outside a running ComfyUI process. Without stubbing them upfront, any `patch()` call against them raises `ModuleNotFoundError` before the test runs.
**Step 1: Create conftest.py**
```python
# tests/conftest.py
import sys
import os
from unittest.mock import MagicMock
# Put the project root on sys.path so tests can import tracker, mapper directly
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
# Stub ComfyUI-only modules before any test file imports project code
for mod in ("folder_paths", "nodes", "server", "folder_paths.folder_names_and_paths"):
if mod not in sys.modules:
sys.modules[mod] = MagicMock()
```
**Step 2: Commit**
```bash
git add tests/conftest.py
git commit -m "test: add conftest with ComfyUI module stubs"
```
---
### Task 1: Extend tracker.py — model_usage schema + methods
**Files:**
- Modify: `tracker.py`
- Create: `tests/test_model_tracker.py`
**Step 1: Add model_usage to SCHEMA**
In `tracker.py`, extend the `SCHEMA` string — append after the existing `CREATE INDEX` lines:
```python
SCHEMA = """
CREATE TABLE IF NOT EXISTS node_usage (
class_type TEXT PRIMARY KEY,
package TEXT NOT NULL,
count INTEGER NOT NULL DEFAULT 0,
first_seen TEXT NOT NULL,
last_seen TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS prompt_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL,
class_types TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS model_usage (
model_name TEXT PRIMARY KEY,
model_type TEXT NOT NULL,
count INTEGER NOT NULL DEFAULT 0,
first_seen TEXT NOT NULL,
last_seen TEXT NOT NULL
);
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_model_usage_type ON model_usage(model_type);
"""
```
**Step 2: Write failing test**
Create `tests/test_model_tracker.py`:
```python
import pytest
import tempfile
import os
from tracker import UsageTracker
@pytest.fixture
def tracker(tmp_path):
return UsageTracker(db_path=str(tmp_path / "test.db"))
def test_record_and_retrieve_model_usage(tracker):
tracker.record_model_usage([("dreamshaper.safetensors", "checkpoints")])
tracker.record_model_usage([("dreamshaper.safetensors", "checkpoints")])
raw = tracker.get_raw_model_stats()
assert len(raw) == 1
assert raw[0]["model_name"] == "dreamshaper.safetensors"
assert raw[0]["model_type"] == "checkpoints"
assert raw[0]["count"] == 2
def test_record_multiple_models(tracker):
tracker.record_model_usage([
("dreamshaper.safetensors", "checkpoints"),
("vae.safetensors", "vae"),
])
raw = tracker.get_raw_model_stats()
assert len(raw) == 2
def test_reset_clears_model_usage(tracker):
tracker.record_model_usage([("model.safetensors", "checkpoints")])
tracker.reset()
assert tracker.get_raw_model_stats() == []
def test_empty_models_returns_empty(tracker):
assert tracker.get_raw_model_stats() == []
```
**Step 3: Run test to confirm it fails**
```bash
cd /media/p5/Comfyui_nodes_stats
python -m pytest tests/test_model_tracker.py -v
```
Expected: `AttributeError: 'UsageTracker' object has no attribute 'record_model_usage'`
**Step 4: Add methods to UsageTracker**
In `tracker.py`, add after `record_usage()`:
```python
def record_model_usage(self, models):
"""Record usage of model files from a single prompt.
models: list of (model_name, model_type) tuples
"""
if not models:
return
now = datetime.now(timezone.utc).isoformat()
with self._lock:
self._ensure_db()
conn = self._connect()
try:
for model_name, model_type in models:
conn.execute(
"""INSERT INTO model_usage (model_name, model_type, count, first_seen, last_seen)
VALUES (?, ?, 1, ?, ?)
ON CONFLICT(model_name) DO UPDATE SET
count = count + 1,
last_seen = excluded.last_seen""",
(model_name, model_type, now, now),
)
conn.commit()
finally:
conn.close()
def get_raw_model_stats(self):
"""Return raw per-model usage rows from DB."""
with self._lock:
self._ensure_db()
conn = self._connect()
try:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"SELECT model_name, model_type, count, first_seen, last_seen "
"FROM model_usage ORDER BY count DESC"
).fetchall()
return [dict(r) for r in rows]
finally:
conn.close()
```
In `reset()`, add before `conn.commit()`:
```python
conn.execute("DELETE FROM model_usage")
```
**Step 5: Add get_model_stats() method**
This method merges DB data with installed models (passed in from caller, so it stays testable):
```python
def get_model_stats(self, installed_by_type):
"""Return per-type grouped model stats with tier classification.
installed_by_type: {model_type: [model_name, ...]} from ModelMapper
"""
db_rows = self.get_raw_model_stats()
db_models = {r["model_name"]: r for r in db_rows}
now = datetime.now(timezone.utc)
one_month_ago = (now - timedelta(days=30)).isoformat()
two_months_ago = (now - timedelta(days=60)).isoformat()
tracking_start = self._get_first_prompt_time()
STATUS_ORDER = {
"safe_to_remove": 0,
"consider_removing": 1,
"unused_new": 2,
"used": 3,
"uninstalled": 4,
}
result_by_type = {}
# Process installed models
for model_type, filenames in installed_by_type.items():
entries = []
for model_name in filenames:
if model_name in db_models:
row = db_models[model_name]
last_seen = row["last_seen"]
if last_seen < two_months_ago:
status = "safe_to_remove"
elif last_seen < one_month_ago:
status = "consider_removing"
else:
status = "used"
entry = {
"model_name": model_name,
"model_type": model_type,
"count": row["count"],
"first_seen": row["first_seen"],
"last_seen": last_seen,
"installed": True,
"status": status,
}
else:
if tracking_start is None:
status = "unused_new"
elif tracking_start < two_months_ago:
status = "safe_to_remove"
elif tracking_start < one_month_ago:
status = "consider_removing"
else:
status = "unused_new"
entry = {
"model_name": model_name,
"model_type": model_type,
"count": 0,
"first_seen": None,
"last_seen": None,
"installed": True,
"status": status,
}
entries.append(entry)
result_by_type[model_type] = entries
# Add uninstalled (in DB but not on disk)
installed_names = {
name for names in installed_by_type.values() for name in names
}
for model_name, row in db_models.items():
if model_name not in installed_names:
model_type = row["model_type"]
result_by_type.setdefault(model_type, []).append({
"model_name": model_name,
"model_type": model_type,
"count": row["count"],
"first_seen": row["first_seen"],
"last_seen": row["last_seen"],
"installed": False,
"status": "uninstalled",
})
# Sort each type's models by status tier then name
result = []
for model_type in sorted(result_by_type):
models = result_by_type[model_type]
models.sort(key=lambda m: (STATUS_ORDER.get(m["status"], 5), m["model_name"]))
result.append({"model_type": model_type, "models": models})
return result
```
**Step 6: Add tests for get_model_stats()**
Append to `tests/test_model_tracker.py`:
```python
from datetime import datetime, timezone, timedelta
from unittest.mock import patch
def test_get_model_stats_used(tracker):
tracker.record_model_usage([("model.safetensors", "checkpoints")])
installed = {"checkpoints": ["model.safetensors"]}
result = tracker.get_model_stats(installed)
assert len(result) == 1
assert result[0]["model_type"] == "checkpoints"
assert result[0]["models"][0]["status"] == "used"
assert result[0]["models"][0]["count"] == 1
def test_get_model_stats_never_used_new(tracker):
installed = {"checkpoints": ["unused.safetensors"]}
result = tracker.get_model_stats(installed)
assert result[0]["models"][0]["status"] == "unused_new"
assert result[0]["models"][0]["count"] == 0
def test_get_model_stats_uninstalled(tracker):
tracker.record_model_usage([("gone.safetensors", "checkpoints")])
installed = {} # no longer on disk
result = tracker.get_model_stats(installed)
assert result[0]["models"][0]["status"] == "uninstalled"
assert result[0]["models"][0]["installed"] is False
def test_get_model_stats_sorted_by_status(tracker):
tracker.record_model_usage([("active.safetensors", "checkpoints")])
installed = {"checkpoints": ["active.safetensors", "unused.safetensors"]}
result = tracker.get_model_stats(installed)
models = result[0]["models"]
statuses = [m["status"] for m in models]
# unused_new (2) comes before used (3) in STATUS_ORDER
assert statuses.index("unused_new") < statuses.index("used")
```
**Step 7: Run all tests**
```bash
python -m pytest tests/test_model_tracker.py -v
```
Expected: all PASS
**Step 8: Commit**
```bash
git add tracker.py tests/test_model_tracker.py
git commit -m "feat: add model_usage table and tracker methods"
```
---
### Task 2: Add ModelMapper to mapper.py
**Files:**
- Modify: `mapper.py`
- Create: `tests/test_model_mapper.py`
**Step 1: Write failing test**
Create `tests/test_model_mapper.py`:
```python
import pytest
from unittest.mock import patch, MagicMock
from mapper import ModelMapper
FAKE_FOLDER_NAMES = {
"checkpoints": ([], {}),
"vae": ([], {}),
"loras": ([], {}),
"configs": ([], {}),
}
FAKE_FILES = {
"checkpoints": ["dream.safetensors", "v15.ckpt"],
"vae": ["vae.safetensors"],
"loras": ["style.safetensors"],
}
def _make_mapper():
# conftest.py already put a MagicMock in sys.modules["folder_paths"],
# so we can configure it directly here.
import folder_paths as fp
fp.folder_names_and_paths = FAKE_FOLDER_NAMES
fp.get_filename_list.side_effect = lambda t: FAKE_FILES.get(t, [])
m = ModelMapper()
m._build()
return m
def test_get_model_type_known(monkeypatch):
m = _make_mapper()
assert m.get_model_type("dream.safetensors") == "checkpoints"
assert m.get_model_type("vae.safetensors") == "vae"
def test_loras_excluded(monkeypatch):
m = _make_mapper()
assert m.get_model_type("style.safetensors") is None
def test_get_all_models(monkeypatch):
m = _make_mapper()
all_models = m.get_all_models()
assert "checkpoints" in all_models
assert "vae" in all_models
assert "loras" not in all_models
assert "dream.safetensors" in all_models["checkpoints"]
def test_unknown_filename_returns_none():
m = _make_mapper()
assert m.get_model_type("nonexistent.ckpt") is None
```
**Step 2: Run test to confirm it fails**
```bash
python -m pytest tests/test_model_mapper.py -v
```
Expected: `ImportError: cannot import name 'ModelMapper' from 'mapper'`
**Step 3: Add ModelMapper to mapper.py**
Append to `mapper.py` after the existing `NodePackageMapper` class:
```python
# Folder types that are not model files and should not be tracked
EXCLUDED_FOLDER_TYPES = {
"loras",
"configs",
"custom_nodes",
"temp",
"output",
"input",
"annotators",
"assets",
}
class ModelMapper:
"""Tracks which folder_paths model types exist and resolves filenames to types."""
def __init__(self):
self._folder_files = None # {folder_type: frozenset(filenames)}
self._reverse = None # {filename: folder_type}
def _build(self):
try:
import folder_paths
self._folder_files = {}
for folder_type in folder_paths.folder_names_and_paths:
if folder_type in EXCLUDED_FOLDER_TYPES:
continue
try:
files = folder_paths.get_filename_list(folder_type)
except Exception:
files = []
if files:
self._folder_files[folder_type] = frozenset(files)
# Reverse map: filename -> folder_type (last write wins on collision)
self._reverse = {}
for folder_type, files in self._folder_files.items():
for f in files:
self._reverse[f] = folder_type
except Exception:
logger.warning("ModelMapper: failed to build model map", exc_info=True)
self._folder_files = {}
self._reverse = {}
def _ensure(self):
if self._folder_files is None:
self._build()
def get_model_type(self, filename):
"""Return the folder type for a filename, or None if not tracked."""
self._ensure()
return self._reverse.get(filename)
def get_all_models(self):
"""Return {folder_type: [filename, ...]} for all tracked types."""
self._ensure()
return {k: sorted(v) for k, v in self._folder_files.items()}
def extract_models_from_prompt(self, prompt):
"""Scan a prompt dict and return (model_name, model_type) pairs.
For each node, inspects INPUT_TYPES() to find list-type (folder dropdown)
inputs, then resolves the selected value against the folder_paths reverse map.
"""
self._ensure()
try:
import nodes as comfy_nodes
except ImportError:
return []
seen = set()
results = []
for node_data in prompt.values():
class_type = node_data.get("class_type")
node_inputs = node_data.get("inputs", {})
if not class_type or not node_inputs:
continue
node_cls = comfy_nodes.NODE_CLASS_MAPPINGS.get(class_type)
if node_cls is None:
continue
try:
input_types = node_cls.INPUT_TYPES()
except Exception:
continue
for category in ("required", "optional"):
for input_name, input_def in input_types.get(category, {}).items():
if not isinstance(input_def, (list, tuple)) or not input_def:
continue
# ComfyUI folder dropdowns have a list as their type
if not isinstance(input_def[0], list):
continue
value = node_inputs.get(input_name)
if not isinstance(value, str) or value in seen:
continue
model_type = self.get_model_type(value)
if model_type:
seen.add(value)
results.append((value, model_type))
return results
def invalidate(self):
"""Force rebuild on next access."""
self._folder_files = None
self._reverse = None
```
**Step 4: Add extract_models_from_prompt test**
Append to `tests/test_model_mapper.py`:
```python
def test_extract_models_from_prompt():
m = _make_mapper()
fake_node_cls = MagicMock()
fake_node_cls.INPUT_TYPES.return_value = {
"required": {
"ckpt_name": (["dream.safetensors", "v15.ckpt"],),
"steps": ("INT", {"default": 20}),
}
}
fake_prompt = {
"1": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": "dream.safetensors", "steps": 20},
}
}
import nodes as comfy_nodes
comfy_nodes.NODE_CLASS_MAPPINGS = {"CheckpointLoaderSimple": fake_node_cls}
results = m.extract_models_from_prompt(fake_prompt)
assert ("dream.safetensors", "checkpoints") in results
def test_extract_models_skips_non_list_inputs():
m = _make_mapper()
fake_node_cls = MagicMock()
fake_node_cls.INPUT_TYPES.return_value = {
"required": {
"text": ("STRING", {}),
}
}
fake_prompt = {"1": {"class_type": "CLIPTextEncode", "inputs": {"text": "hello"}}}
import nodes as comfy_nodes
comfy_nodes.NODE_CLASS_MAPPINGS = {"CLIPTextEncode": fake_node_cls}
results = m.extract_models_from_prompt(fake_prompt)
assert results == []
```
**Step 5: Run all tests**
```bash
python -m pytest tests/test_model_mapper.py -v
```
Expected: all PASS
**Step 6: Commit**
```bash
git add mapper.py tests/test_model_mapper.py
git commit -m "feat: add ModelMapper with folder_paths introspection"
```
---
### Task 3: Extend __init__.py — prompt handler + API endpoint
**Files:**
- Modify: `__init__.py`
No automated tests here (requires live ComfyUI server). Test manually after Task 4.
**Step 1: Import ModelMapper and create instance**
At the top of `__init__.py`, after existing imports:
```python
from .mapper import NodePackageMapper, ModelMapper
```
After `tracker = UsageTracker()`, add:
```python
model_mapper = ModelMapper()
```
**Step 2: Extend on_prompt_handler to extract models**
Replace the existing `on_prompt_handler` function:
```python
def on_prompt_handler(json_data):
"""Called on every prompt submission. Extracts class_types and queues recording."""
try:
prompt = json_data.get("prompt", {})
class_types = set()
for node_id, node_data in prompt.items():
ct = node_data.get("class_type")
if ct:
class_types.add(ct)
if class_types:
# Pass the full prompt to the thread — model extraction (which calls
# INPUT_TYPES() on every node) happens off the main request thread.
threading.Thread(
target=_record_prompt,
args=(class_types, prompt),
daemon=True,
).start()
except Exception:
logger.warning("nodes-stats: error recording usage", exc_info=True)
return json_data
def _record_prompt(class_types, prompt):
tracker.record_usage(class_types, mapper)
models = model_mapper.extract_models_from_prompt(prompt)
if models:
tracker.record_model_usage(models)
```
**Step 3: Extend reset endpoint to invalidate model_mapper**
In the `reset_stats` route handler, add `model_mapper.invalidate()` after `mapper.invalidate()`:
```python
@routes.post("/nodes-stats/reset")
async def reset_stats(request):
try:
tracker.reset()
mapper.invalidate()
model_mapper.invalidate()
return web.json_response({"status": "ok"})
except Exception:
logger.error("nodes-stats: error resetting stats", exc_info=True)
return web.json_response({"error": "internal error"}, status=500)
```
**Step 4: Add /nodes-stats/models endpoint**
After the existing `/nodes-stats/usage` route:
```python
@routes.get("/nodes-stats/models")
async def get_model_stats(request):
try:
installed_by_type = model_mapper.get_all_models()
stats = tracker.get_model_stats(installed_by_type)
return web.json_response(stats)
except Exception:
logger.error("nodes-stats: error getting model stats", exc_info=True)
return web.json_response({"error": "internal error"}, status=500)
```
**Step 5: Commit**
```bash
git add __init__.py
git commit -m "feat: extend prompt handler and add /nodes-stats/models endpoint"
```
---
### Task 4: Frontend — tab switcher + Models tab
**Files:**
- Modify: `js/nodes_stats.js`
**Step 1: Update showStatsDialog to fetch models data**
Replace the fetch at the top of `showStatsDialog()`:
```javascript
async function showStatsDialog() {
let data, modelData;
try {
const [pkgResp, modelResp] = await Promise.all([
fetch("/nodes-stats/packages"),
fetch("/nodes-stats/models"),
]);
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; }
data = await pkgResp.json();
modelData = await modelResp.json();
if (!Array.isArray(data) || !Array.isArray(modelData)) {
alert("Failed to load stats: unexpected response format");
return;
}
} catch (e) {
alert("Failed to load stats: " + e.message);
return;
}
// ... rest of function
```
**Step 2: Replace dialog html construction with tabbed layout**
After the existing `let html = ...` header block (the title + close button), replace the rest of the dialog HTML building with:
```javascript
// Tab switcher — no onclick attributes, wired via addEventListener after insertion
html += `
<div style="display:flex;gap:0;margin-bottom:20px;border-bottom:1px solid #333;">
<button id="ns-tab-nodes"
style="background:none;border:none;border-bottom:2px solid #4a4;color:#4a4;padding:8px 18px;cursor:pointer;font-family:monospace;font-size:13px;font-weight:bold;">
Nodes
</button>
<button id="ns-tab-models"
style="background:none;border:none;border-bottom:2px solid transparent;color:#888;padding:8px 18px;cursor:pointer;font-family:monospace;font-size:13px;">
Models
</button>
</div>`;
// Nodes tab content (existing content, wrapped)
html += `<div id="ns-content-nodes">`;
html += buildNodesTabContent(custom);
html += `</div>`;
// Models tab content
html += `<div id="ns-content-models" style="display:none;">`;
html += buildModelsTabContent(modelData);
html += `</div>`;
dialog.innerHTML = html;
overlay.appendChild(dialog);
document.body.appendChild(overlay);
// Tab switch — local function, no window pollution
function switchTab(tab) {
dialog.querySelector("#ns-content-nodes").style.display = tab === "nodes" ? "" : "none";
dialog.querySelector("#ns-content-models").style.display = tab === "models" ? "" : "none";
const nodeBtn = dialog.querySelector("#ns-tab-nodes");
const modelBtn = dialog.querySelector("#ns-tab-models");
nodeBtn.style.borderBottomColor = tab === "nodes" ? "#4a4" : "transparent";
nodeBtn.style.color = tab === "nodes" ? "#4a4" : "#888";
nodeBtn.style.fontWeight = tab === "nodes" ? "bold" : "normal";
modelBtn.style.borderBottomColor = tab === "models" ? "#4a4" : "transparent";
modelBtn.style.color = tab === "models" ? "#4a4" : "#888";
modelBtn.style.fontWeight = tab === "models" ? "bold" : "normal";
}
dialog.querySelector("#ns-tab-nodes").addEventListener("click", () => switchTab("nodes"));
dialog.querySelector("#ns-tab-models").addEventListener("click", () => switchTab("models"));
```
**Step 3: Extract existing content into buildNodesTabContent()**
Move the existing badge bar + section rendering into a new function. Extract the block that builds the summary badges and sections (everything from the `<div style="display:flex;gap:10px...">` badges to the end of the section tables) into:
```javascript
function buildNodesTabContent(custom) {
const safeToRemove = custom.filter((p) => p.status === "safe_to_remove");
const considerRemoving = custom.filter((p) => p.status === "consider_removing");
const unusedNew = custom.filter((p) => p.status === "unused_new");
const used = custom.filter((p) => p.status === "used");
const uninstalled = custom.filter((p) => p.status === "uninstalled");
let html = `<div style="display:flex;gap:10px;margin-bottom:20px;flex-wrap:wrap;">
<div style="background:#3a1a1a;padding:8px 14px;border-radius:4px;border-left:3px solid #e44;">
<span style="font-size:20px;font-weight:bold;color:#e44;">${safeToRemove.length}</span>
<span style="color:#c99;margin-left:6px;">safe to remove</span>
</div>
<div style="background:#2a2215;padding:8px 14px;border-radius:4px;border-left:3px solid #e90;">
<span style="font-size:20px;font-weight:bold;color:#e90;">${considerRemoving.length}</span>
<span style="color:#ca8;margin-left:6px;">consider removing</span>
</div>
<div style="background:#1a1a2a;padding:8px 14px;border-radius:4px;border-left:3px solid #68f;">
<span style="font-size:20px;font-weight:bold;color:#68f;">${unusedNew.length}</span>
<span style="color:#99b;margin-left:6px;">unused &lt;1 month</span>
</div>
<div id="nodes-stats-used-badge" style="background:#1a2a1a;padding:8px 14px;border-radius:4px;border-left:3px solid #4a4;cursor:default;user-select:none;">
<span style="font-size:20px;font-weight:bold;color:#4a4;">${used.length}</span>
<span style="color:#9c9;margin-left:6px;">used</span>
</div>
</div>`;
if (safeToRemove.length > 0) html += sectionHeader("Safe to Remove", "Unused for 2+ months", "#e44") + buildTable(safeToRemove, "safe_to_remove");
if (considerRemoving.length > 0) html += sectionHeader("Consider Removing", "Unused for 1-2 months", "#e90") + buildTable(considerRemoving, "consider_removing");
if (unusedNew.length > 0) html += sectionHeader("Recently Unused", "Unused for less than 1 month", "#68f") + buildTable(unusedNew, "unused_new");
if (used.length > 0) html += sectionHeader("Used", "", "#4a4") + buildTable(used, "used");
if (uninstalled.length > 0) html += sectionHeader("Uninstalled", "Previously tracked, no longer installed", "#555") + buildTable(uninstalled, "uninstalled");
return html;
}
```
**Step 4: Write buildModelsTabContent()**
Add this new function:
```javascript
function buildModelsTabContent(modelData) {
// Flatten for summary counts
const allModels = modelData.flatMap((g) => g.models);
const safeCount = allModels.filter((m) => m.status === "safe_to_remove").length;
const considerCount = allModels.filter((m) => m.status === "consider_removing").length;
const unusedNewCount = allModels.filter((m) => m.status === "unused_new").length;
const usedCount = allModels.filter((m) => m.status === "used").length;
let html = `<div style="display:flex;gap:10px;margin-bottom:20px;flex-wrap:wrap;">
<div style="background:#3a1a1a;padding:8px 14px;border-radius:4px;border-left:3px solid #e44;">
<span style="font-size:20px;font-weight:bold;color:#e44;">${safeCount}</span>
<span style="color:#c99;margin-left:6px;">safe to remove</span>
</div>
<div style="background:#2a2215;padding:8px 14px;border-radius:4px;border-left:3px solid #e90;">
<span style="font-size:20px;font-weight:bold;color:#e90;">${considerCount}</span>
<span style="color:#ca8;margin-left:6px;">consider removing</span>
</div>
<div style="background:#1a1a2a;padding:8px 14px;border-radius:4px;border-left:3px solid #68f;">
<span style="font-size:20px;font-weight:bold;color:#68f;">${unusedNewCount}</span>
<span style="color:#99b;margin-left:6px;">unused &lt;1 month</span>
</div>
<div style="background:#1a2a1a;padding:8px 14px;border-radius:4px;border-left:3px solid #4a4;">
<span style="font-size:20px;font-weight:bold;color:#4a4;">${usedCount}</span>
<span style="color:#9c9;margin-left:6px;">used</span>
</div>
</div>`;
if (allModels.length === 0) {
html += `<p style="color:#666;">No models tracked yet. Run a workflow to start.</p>`;
return html;
}
for (const group of modelData) {
if (group.models.length === 0) continue;
const title = group.model_type.charAt(0).toUpperCase() + group.model_type.slice(1).replace(/_/g, " ");
html += sectionHeader(title, `${group.models.length} model${group.models.length !== 1 ? "s" : ""}`, "#4a4");
html += buildModelTable(group.models);
}
return html;
}
function buildModelTable(models) {
let html = `<table style="width:100%;border-collapse:collapse;margin-bottom:12px;">
<thead><tr style="color:#888;text-align:left;border-bottom:1px solid #333;">
<th style="padding:6px 8px;">Model</th>
<th style="padding:6px 8px;text-align:right;">Executions</th>
<th style="padding:6px 8px;">Last Used</th>
<th style="padding:6px 8px;">Status</th>
</tr></thead><tbody>`;
for (const m of models) {
const { bg, hover } = STATUS_COLORS[m.status] || STATUS_COLORS.used;
const lastSeen = m.last_seen ? new Date(m.last_seen).toLocaleDateString() : "—";
const statusLabel = {
safe_to_remove: { text: "safe to remove", color: "#e44" },
consider_removing: { text: "consider removing", color: "#e90" },
unused_new: { text: "unused <1mo", color: "#68f" },
used: { text: "used", color: "#4a4" },
uninstalled: { text: "uninstalled", color: "#555" },
}[m.status] || { text: m.status, color: "#888" };
html += `<tr style="background:${bg};border-bottom:1px solid #222;"
onmouseover="this.style.background='${hover}'" onmouseout="this.style.background='${bg}'">
<td style="padding:6px 8px;color:#fff;">${escapeHtml(m.model_name)}</td>
<td style="padding:6px 8px;text-align:right;">${m.count}</td>
<td style="padding:6px 8px;color:#888;">${lastSeen}</td>
<td style="padding:6px 8px;"><span style="color:${statusLabel.color};font-size:11px;">${statusLabel.text}</span></td>
</tr>`;
}
html += `</tbody></table>`;
return html;
}
```
**Step 5: Remove duplicated variable declarations**
The variables `safeToRemove`, `considerRemoving`, `unusedNew`, `used`, `uninstalled` that were at the top of `showStatsDialog` are now inside `buildNodesTabContent` — remove them from `showStatsDialog`.
Also remove the easter egg badge wiring from after `dialog.innerHTML = html` since it's now inside the nodes tab. Move it after the `nsShowTab` assignment, but target the badge which is inside `ns-content-nodes`:
```javascript
// Easter egg: click "used" badge 5 times to show podium
let eggClicks = 0;
let eggTimer = null;
const usedBadge = document.getElementById("nodes-stats-used-badge");
if (usedBadge) {
usedBadge.addEventListener("click", () => {
eggClicks++;
clearTimeout(eggTimer);
eggTimer = setTimeout(() => (eggClicks = 0), 1500);
if (eggClicks >= 5) {
eggClicks = 0;
const allNodes = custom
.flatMap((p) => p.nodes.map((n) => ({ ...n, pkg: p.package })))
.sort((a, b) => b.count - a.count);
showPodium(allNodes.slice(0, 3), overlay);
}
});
}
```
Note: `custom` needs to be accessible here — declare it at the top of `showStatsDialog` before the html building:
```javascript
const custom = data.filter((p) => p.package !== "__builtin__");
```
**Step 6: Wire close button and pkg-row toggles**
These are already present; make sure they remain after the new `dialog.innerHTML = html` assignment:
```javascript
document.getElementById("nodes-stats-close").addEventListener("click", () => overlay.remove());
dialog.querySelectorAll(".pkg-row").forEach((row) => {
row.addEventListener("click", () => {
const detail = row.nextElementSibling;
if (detail && detail.classList.contains("pkg-detail")) {
detail.style.display = detail.style.display === "none" ? "table-row" : "none";
const arrow = row.querySelector(".arrow");
if (arrow) arrow.textContent = detail.style.display === "none" ? "▶" : "▼";
}
});
});
```
**Step 7: Manual test**
1. Restart ComfyUI
2. Open a workflow that uses a checkpoint (e.g. CheckpointLoaderSimple)
3. Queue a prompt
4. Click the Node Stats button → should see "Nodes" and "Models" tabs
5. Switch to Models tab → checkpoint should appear under "Checkpoints" with count ≥ 1
6. Other installed checkpoints with no usage should show as `unused_new` (or `safe_to_remove` if tracking has been running >2mo)
**Step 8: Commit**
```bash
git add js/nodes_stats.js
git commit -m "feat: add Models tab with per-type usage stats"
```
---
### Task 5: Final check and cleanup
**Step 1: Run full test suite**
```bash
python -m pytest tests/ -v
```
Expected: all PASS
**Step 2: Verify reset clears model data**
```bash
curl -X POST http://localhost:8188/nodes-stats/reset
curl http://localhost:8188/nodes-stats/models | python3 -m json.tool
# Expect: models with count=0 and status=unused_new (none tracked yet)
```
**Step 3: Commit if any cleanup needed**
```bash
git add -p
git commit -m "chore: cleanup after model tracking implementation"
```