Compare commits
2
Commits
8a71013247
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54f9a3ae29 | ||
|
|
228542c71a |
@@ -67,27 +67,31 @@ Click the **Node Stats** button (bar chart icon) in the ComfyUI top menu bar. A
|
||||
- Summary bar with counts for each classification tier
|
||||
- Sections for each tier, sorted from most actionable to least
|
||||
- Expandable rows — click any package to see per-node execution counts and timestamps
|
||||
- **Disable** buttons on the "Safe to Remove" and "Consider Removing" tiers (see below)
|
||||
- **Disable** buttons on the "Safe to Remove", "Consider Removing", and "Recently Unused" tiers (see below)
|
||||
|
||||
### Disabling unused packages
|
||||
|
||||
When [ComfyUI Manager](https://github.com/ltdrdata/ComfyUI-Manager) is installed, the
|
||||
"Safe to Remove" and "Consider Removing" sections show a **Disable** button on each
|
||||
package, plus a **Disable all** button per section. Disabling:
|
||||
The "Safe to Remove", "Consider Removing", and "Recently Unused" sections show
|
||||
a **Disable** button on each package, plus a **Disable all** button per section.
|
||||
The latter is useful for a package you installed but never used. Disabling:
|
||||
|
||||
- Hands off to ComfyUI Manager, which moves the package into `custom_nodes/.disabled/`
|
||||
- Is fully reversible — re-enable any package from ComfyUI Manager whenever you like
|
||||
- Uses ComfyUI Manager when it manages the package, otherwise moves it directly
|
||||
into `custom_nodes/.disabled/`
|
||||
- Is fully reversible — the *Uninstalled* section offers **Enable** for every
|
||||
package found in `.disabled/`, even if it was never run
|
||||
- Requires a ComfyUI restart to unload the package from the running session (a banner
|
||||
with a **Restart ComfyUI** button appears after disabling)
|
||||
|
||||
If ComfyUI Manager is not installed, the disable buttons are hidden and stats work as before.
|
||||
ComfyUI Manager is optional for these actions; packages it does not manage are
|
||||
disabled and re-enabled directly by Node Stats.
|
||||
|
||||
> **Manager compatibility:** works with both the standalone
|
||||
> [ComfyUI-Manager](https://github.com/ltdrdata/ComfyUI-Manager) custom node and
|
||||
> ComfyUI core's built-in manager. For the built-in manager, launch ComfyUI with
|
||||
> `--enable-manager-legacy-ui` (the extension detects the active manager and its
|
||||
> API automatically). Without a reachable manager, all enable/disable/install
|
||||
> actions are simply omitted.
|
||||
> API automatically). Without a reachable manager, Node Stats still supports
|
||||
> direct enable/disable; Manager-only workflow install and trial actions are
|
||||
> unavailable.
|
||||
|
||||
### Workflow tab & temporary enable
|
||||
|
||||
@@ -158,6 +162,7 @@ a clear message) when ComfyUI Manager is absent or there are no disabled package
|
||||
- Summary bar with counts for each tier across all model types
|
||||
- Sections per model type (checkpoints, vae, controlnet, …)
|
||||
- Per-model table showing execution count, last used date, and status
|
||||
- Select one or more installed models and permanently delete them after confirmation
|
||||
|
||||
### API
|
||||
|
||||
|
||||
+33
-1
@@ -78,7 +78,13 @@ routes = PromptServer.instance.routes
|
||||
@routes.get("/nodes-stats/packages")
|
||||
async def get_package_stats(request):
|
||||
try:
|
||||
stats = tracker.get_package_stats(mapper)
|
||||
# Include entries parked in custom_nodes/.disabled even when they were
|
||||
# never executed. They are otherwise absent from both the DB and the
|
||||
# live node mapper, leaving no way to re-enable them in the UI.
|
||||
disabled = await asyncio.get_event_loop().run_in_executor(
|
||||
None, list_disabled_packs
|
||||
)
|
||||
stats = tracker.get_package_stats(mapper, disabled)
|
||||
return web.json_response(stats)
|
||||
except Exception:
|
||||
logger.error("nodes-stats: error getting package stats", exc_info=True)
|
||||
@@ -106,6 +112,32 @@ async def get_model_stats(request):
|
||||
return web.json_response({"error": "internal error"}, status=500)
|
||||
|
||||
|
||||
@routes.post("/nodes-stats/models/delete")
|
||||
async def delete_models(request):
|
||||
"""Delete one or more currently installed model files.
|
||||
|
||||
The mapper validates each supplied (type, name) pair against ComfyUI's
|
||||
current model folders before unlinking it, rather than trusting a path sent
|
||||
by the browser.
|
||||
"""
|
||||
try:
|
||||
data = await request.json()
|
||||
models = data.get("models")
|
||||
if not isinstance(models, list) or not models:
|
||||
return web.json_response({"error": "models must be a non-empty list"}, status=400)
|
||||
if len(models) > 500:
|
||||
return web.json_response({"error": "too many models"}, status=400)
|
||||
results = await asyncio.get_event_loop().run_in_executor(
|
||||
None, model_mapper.delete_models, models
|
||||
)
|
||||
deleted = [r for r in results if r.get("status") == "deleted"]
|
||||
failed = [r for r in results if r.get("status") != "deleted"]
|
||||
return web.json_response({"deleted": deleted, "failed": failed})
|
||||
except Exception:
|
||||
logger.error("nodes-stats: error deleting models", exc_info=True)
|
||||
return web.json_response({"error": "internal error"}, status=500)
|
||||
|
||||
|
||||
@routes.post("/nodes-stats/reset")
|
||||
async def reset_stats(request):
|
||||
try:
|
||||
|
||||
+100
-8
@@ -21,8 +21,10 @@ const STATUS_META = {
|
||||
uninstalled: { label: "uninstalled", color: "#555", bg: "#1a1a1a", hover: "#252525", summaryBg: "#1a1a1a", summaryText: "#888" },
|
||||
};
|
||||
|
||||
// Tiers that may offer a "Disable" action (when ComfyUI Manager is available).
|
||||
const DISABLEABLE_TIERS = new Set(["safe_to_remove", "consider_removing"]);
|
||||
// Tiers that may offer a "Disable" action. A package never used during the
|
||||
// first month is safe to disable manually too; waiting a month would otherwise
|
||||
// strand common one-off installs such as VAE Decode PlusPlus.
|
||||
const DISABLEABLE_TIERS = new Set(["safe_to_remove", "consider_removing", "unused_new"]);
|
||||
|
||||
// Setting id for the auto-open-on-load behavior. Defaults to off so loading a
|
||||
// workflow with missing/disabled nodes no longer pops the dialog every time.
|
||||
@@ -256,6 +258,7 @@ async function showStatsDialog(initialTab = "nodes") {
|
||||
wireWorkflowButtons(dialog);
|
||||
wireWhitelistButtons(dialog);
|
||||
wireNativeEnableButtons(dialog);
|
||||
wireModelDeleteControls(dialog);
|
||||
|
||||
switchTab(TABS.includes(initialTab) ? initialTab : "nodes");
|
||||
|
||||
@@ -430,6 +433,11 @@ function buildModelsTabContent(modelData) {
|
||||
return html;
|
||||
}
|
||||
|
||||
html += `<div id="ns-model-actions" style="display:flex;align-items:center;justify-content:flex-end;gap:10px;flex-wrap:wrap;margin:-4px 0 12px;">
|
||||
<span id="ns-model-selection-count" style="color:#888;font-size:12px;">No models selected</span>
|
||||
<button id="ns-delete-selected-models" class="ns-btn" disabled>Delete selected</button>
|
||||
</div>`;
|
||||
|
||||
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, " ");
|
||||
@@ -441,30 +449,114 @@ function buildModelsTabContent(modelData) {
|
||||
}
|
||||
|
||||
function buildModelTable(models) {
|
||||
let html = `<table style="width:100%;border-collapse:collapse;margin-bottom:12px;">
|
||||
let html = `<div style="max-width:100%;overflow-x:auto;margin-bottom:12px;"><table style="width:100%;min-width:620px;table-layout:fixed;border-collapse:collapse;">
|
||||
<thead><tr style="color:#888;text-align:left;border-bottom:1px solid #333;">
|
||||
<th style="padding:6px 8px;width:28px;"><input class="ns-model-select-all" type="checkbox" title="Select all installed models in this group" aria-label="Select all installed models in this group"></th>
|
||||
<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>
|
||||
<th style="padding:6px 8px;width:82px;text-align:right;">Executions</th>
|
||||
<th style="padding:6px 8px;width:76px;">Last Used</th>
|
||||
<th style="padding:6px 8px;width:112px;">Status</th>
|
||||
<th style="padding:6px 8px;width:68px;text-align:right;"></th>
|
||||
</tr></thead><tbody>`;
|
||||
|
||||
for (const m of models) {
|
||||
const meta = STATUS_META[m.status] || STATUS_META.used;
|
||||
const lastSeen = m.last_seen ? new Date(m.last_seen).toLocaleDateString() : "—";
|
||||
|
||||
const selectable = m.installed ? "" : " disabled";
|
||||
const action = m.installed
|
||||
? `<button class="ns-btn ns-delete-model-btn" data-model-type="${escapeAttr(m.model_type)}" data-model-name="${escapeAttr(m.model_name)}">Delete</button>`
|
||||
: `<span style="color:#555;">—</span>`;
|
||||
|
||||
html += `<tr class="ns-row-${m.status}" style="border-bottom:1px solid #222;">
|
||||
<td style="padding:6px 8px;color:#fff;">${escapeHtml(m.model_name)}</td>
|
||||
<td style="padding:6px 8px;"><input class="ns-model-select" type="checkbox" data-model-type="${escapeAttr(m.model_type)}" data-model-name="${escapeAttr(m.model_name)}" aria-label="Select ${escapeAttr(m.model_name)}"${selectable}></td>
|
||||
<td style="padding:6px 8px;color:#fff;overflow-wrap:anywhere;word-break:break-word;">${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:${meta.color};font-size:11px;">${meta.label}</span></td>
|
||||
<td style="padding:6px 8px;text-align:right;white-space:nowrap;">${action}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
html += `</tbody></table>`;
|
||||
html += `</tbody></table></div>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
// Model deletion is intentionally separate from package disable: the selected
|
||||
// file is deleted permanently, so every request is confirmed and the backend
|
||||
// validates that it belongs to a configured ComfyUI model folder.
|
||||
function wireModelDeleteControls(dialog) {
|
||||
const selectionCount = dialog.querySelector("#ns-model-selection-count");
|
||||
const deleteSelected = dialog.querySelector("#ns-delete-selected-models");
|
||||
if (!selectionCount || !deleteSelected) return;
|
||||
|
||||
const selectedModels = () => [...dialog.querySelectorAll(".ns-model-select:checked")].map((box) => ({
|
||||
model_type: box.dataset.modelType,
|
||||
model_name: box.dataset.modelName,
|
||||
}));
|
||||
|
||||
const updateSelection = () => {
|
||||
const selected = dialog.querySelectorAll(".ns-model-select:checked").length;
|
||||
selectionCount.textContent = selected ? `${selected} model${selected === 1 ? "" : "s"} selected` : "No models selected";
|
||||
deleteSelected.disabled = selected === 0;
|
||||
dialog.querySelectorAll(".ns-model-select-all").forEach((master) => {
|
||||
const choices = [...master.closest("table").querySelectorAll(".ns-model-select:not(:disabled)")];
|
||||
const checked = choices.filter((box) => box.checked).length;
|
||||
master.checked = choices.length > 0 && checked === choices.length;
|
||||
master.indeterminate = checked > 0 && checked < choices.length;
|
||||
master.disabled = choices.length === 0;
|
||||
});
|
||||
};
|
||||
|
||||
dialog.querySelectorAll(".ns-model-select").forEach((box) => box.addEventListener("change", updateSelection));
|
||||
dialog.querySelectorAll(".ns-model-select-all").forEach((master) => master.addEventListener("change", () => {
|
||||
master.closest("table").querySelectorAll(".ns-model-select:not(:disabled)").forEach((box) => {
|
||||
box.checked = master.checked;
|
||||
});
|
||||
updateSelection();
|
||||
}));
|
||||
dialog.querySelectorAll(".ns-delete-model-btn").forEach((button) => button.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
deleteModels([{ model_type: button.dataset.modelType, model_name: button.dataset.modelName }], dialog);
|
||||
}));
|
||||
deleteSelected.addEventListener("click", () => deleteModels(selectedModels(), dialog));
|
||||
updateSelection();
|
||||
}
|
||||
|
||||
async function deleteModels(models, dialog) {
|
||||
if (!models.length) return;
|
||||
const label = models.length === 1 ? `"${models[0].model_name}"` : `${models.length} selected models`;
|
||||
if (!confirm(`Permanently delete ${label}?\n\nThis removes the model files from disk and cannot be undone.`)) return;
|
||||
|
||||
const controls = dialog.querySelectorAll(".ns-model-select, .ns-model-select-all, .ns-delete-model-btn, #ns-delete-selected-models");
|
||||
controls.forEach((control) => { control.disabled = true; });
|
||||
try {
|
||||
const response = await fetch("/nodes-stats/models/delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ models }),
|
||||
});
|
||||
let result = {};
|
||||
try { result = await response.json(); } catch { /* use the HTTP status below */ }
|
||||
if (!response.ok) throw new Error(result.error || `HTTP ${response.status}`);
|
||||
|
||||
const deleted = result.deleted || [];
|
||||
const failed = result.failed || [];
|
||||
if (deleted.length) {
|
||||
notify(`Deleted ${deleted.length} model${deleted.length === 1 ? "" : "s"}.`, "success");
|
||||
showStatsDialog("models"); // Re-read folders; deleted files remain as historical entries.
|
||||
}
|
||||
if (failed.length) {
|
||||
const names = failed.map((item) => item.model_name || item.message || "unknown").join(", ");
|
||||
notify(`Could not delete: ${names}`, "error");
|
||||
}
|
||||
} catch (error) {
|
||||
notify("Failed to delete model: " + error.message, "error");
|
||||
} finally {
|
||||
if (dialog.isConnected) controls.forEach((control) => { control.disabled = false; });
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -132,6 +132,76 @@ class ModelMapper:
|
||||
self._ensure()
|
||||
return {k: sorted(v) for k, v in self._folder_files.items()}
|
||||
|
||||
def delete_models(self, models):
|
||||
"""Delete verified model files and return per-model results.
|
||||
|
||||
Each requested file must still be in the mapper's known model list and
|
||||
resolve beneath the configured root for its model type. This keeps the
|
||||
HTTP endpoint from being usable to remove arbitrary files.
|
||||
"""
|
||||
self._ensure()
|
||||
try:
|
||||
import folder_paths
|
||||
except ImportError:
|
||||
return [{"status": "error", "message": "folder_paths unavailable"}]
|
||||
|
||||
results = []
|
||||
seen = set()
|
||||
for item in models:
|
||||
if not isinstance(item, dict):
|
||||
results.append({"status": "error", "message": "invalid model entry"})
|
||||
continue
|
||||
model_type = item.get("model_type")
|
||||
model_name = item.get("model_name")
|
||||
if not isinstance(model_type, str) or not isinstance(model_name, str):
|
||||
results.append({
|
||||
"model_type": model_type,
|
||||
"model_name": model_name,
|
||||
"status": "error",
|
||||
"message": "invalid model entry",
|
||||
})
|
||||
continue
|
||||
key = (model_type, model_name)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
result = {"model_type": model_type, "model_name": model_name}
|
||||
|
||||
known = self._folder_files.get(model_type, frozenset())
|
||||
if model_name not in known:
|
||||
result.update(status="error", message="model is not installed")
|
||||
results.append(result)
|
||||
continue
|
||||
|
||||
try:
|
||||
path = folder_paths.get_full_path(model_type, model_name)
|
||||
roots = folder_paths.get_folder_paths(model_type)
|
||||
path = os.path.abspath(path) if path else None
|
||||
allowed = any(
|
||||
os.path.commonpath([path, os.path.abspath(root)]) == os.path.abspath(root)
|
||||
for root in roots
|
||||
) if path else False
|
||||
except (OSError, TypeError, ValueError):
|
||||
path = None
|
||||
allowed = False
|
||||
|
||||
if not path or not allowed or not os.path.isfile(path):
|
||||
result.update(status="error", message="model file not found in its configured folder")
|
||||
results.append(result)
|
||||
continue
|
||||
|
||||
try:
|
||||
os.remove(path)
|
||||
result["status"] = "deleted"
|
||||
except OSError as exc:
|
||||
result.update(status="error", message=str(exc))
|
||||
results.append(result)
|
||||
|
||||
# The model-folder listing is now stale whether any deletion succeeded
|
||||
# or not (another process could have changed it), so rebuild next time.
|
||||
self.invalidate()
|
||||
return results
|
||||
|
||||
def extract_models_from_prompt(self, prompt):
|
||||
"""Scan a prompt dict and return (model_name, model_type) pairs.
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
from tracker import UsageTracker
|
||||
|
||||
|
||||
class _Mapper:
|
||||
def __init__(self, mapping=()):
|
||||
self.mapping = dict(mapping)
|
||||
|
||||
def get_package(self, class_type):
|
||||
return self.mapping.get(class_type, "__unknown__")
|
||||
|
||||
def get_all_packages(self):
|
||||
return set(self.mapping.values()) - {"__builtin__"}
|
||||
|
||||
|
||||
def test_never_used_disabled_package_stays_visible_for_enable(tmp_path):
|
||||
tracker = UsageTracker(db_path=str(tmp_path / "test.db"))
|
||||
|
||||
stats = tracker.get_package_stats(
|
||||
_Mapper(), disabled_packages={"VAE-Decode-PlusPlus"}
|
||||
)
|
||||
|
||||
assert stats == [{
|
||||
"package": "VAE-Decode-PlusPlus",
|
||||
"total_executions": 0,
|
||||
"used_nodes": 0,
|
||||
"nodes": [],
|
||||
"last_seen": None,
|
||||
"total_nodes": 0,
|
||||
"installed": False,
|
||||
"status": "uninstalled",
|
||||
"whitelisted": False,
|
||||
}]
|
||||
|
||||
|
||||
def test_disabled_entry_does_not_override_an_active_package(tmp_path):
|
||||
tracker = UsageTracker(db_path=str(tmp_path / "test.db"))
|
||||
|
||||
stats = tracker.get_package_stats(
|
||||
_Mapper({"Node": "VAE-Decode-PlusPlus"}),
|
||||
disabled_packages={"vae-decode-plusplus"},
|
||||
)
|
||||
|
||||
assert len(stats) == 1
|
||||
assert stats[0]["package"] == "VAE-Decode-PlusPlus"
|
||||
assert stats[0]["installed"] is True
|
||||
@@ -0,0 +1,66 @@
|
||||
from mapper import ModelMapper
|
||||
|
||||
|
||||
def _configure_model_paths(monkeypatch, tmp_path, filenames):
|
||||
import folder_paths
|
||||
|
||||
models_root = tmp_path / "models"
|
||||
models_root.mkdir()
|
||||
for filename in filenames:
|
||||
target = models_root / filename
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(b"model")
|
||||
|
||||
monkeypatch.setattr(folder_paths, "folder_names_and_paths", {"checkpoints": ([], {})})
|
||||
monkeypatch.setattr(folder_paths, "get_filename_list", lambda model_type: filenames if model_type == "checkpoints" else [])
|
||||
monkeypatch.setattr(folder_paths, "get_folder_paths", lambda model_type: [str(models_root)] if model_type == "checkpoints" else [])
|
||||
monkeypatch.setattr(folder_paths, "get_full_path", lambda model_type, filename: str(models_root / filename))
|
||||
return models_root
|
||||
|
||||
|
||||
def test_delete_models_removes_only_a_known_model(monkeypatch, tmp_path):
|
||||
models_root = _configure_model_paths(monkeypatch, tmp_path, ["nested/model.safetensors"])
|
||||
mapper = ModelMapper()
|
||||
|
||||
result = mapper.delete_models([{
|
||||
"model_type": "checkpoints",
|
||||
"model_name": "nested/model.safetensors",
|
||||
}])
|
||||
|
||||
assert result == [{
|
||||
"model_type": "checkpoints",
|
||||
"model_name": "nested/model.safetensors",
|
||||
"status": "deleted",
|
||||
}]
|
||||
assert not (models_root / "nested/model.safetensors").exists()
|
||||
|
||||
|
||||
def test_delete_models_rejects_unknown_name(monkeypatch, tmp_path):
|
||||
models_root = _configure_model_paths(monkeypatch, tmp_path, ["known.safetensors"])
|
||||
mapper = ModelMapper()
|
||||
|
||||
result = mapper.delete_models([{
|
||||
"model_type": "checkpoints",
|
||||
"model_name": "../outside.safetensors",
|
||||
}])
|
||||
|
||||
assert result[0]["status"] == "error"
|
||||
assert (models_root / "known.safetensors").exists()
|
||||
|
||||
|
||||
def test_delete_models_rejects_path_outside_model_root(monkeypatch, tmp_path):
|
||||
models_root = _configure_model_paths(monkeypatch, tmp_path, ["known.safetensors"])
|
||||
outside = tmp_path / "outside.safetensors"
|
||||
outside.write_bytes(b"do not remove")
|
||||
import folder_paths
|
||||
monkeypatch.setattr(folder_paths, "get_full_path", lambda *_: str(outside))
|
||||
mapper = ModelMapper()
|
||||
|
||||
result = mapper.delete_models([{
|
||||
"model_type": "checkpoints",
|
||||
"model_name": "known.safetensors",
|
||||
}])
|
||||
|
||||
assert result[0]["status"] == "error"
|
||||
assert (models_root / "known.safetensors").exists()
|
||||
assert outside.exists()
|
||||
+31
-5
@@ -217,8 +217,14 @@ class UsageTracker:
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_package_stats(self, mapper):
|
||||
"""Aggregate per-package stats combining DB data with known nodes."""
|
||||
def get_package_stats(self, mapper, disabled_packages=()):
|
||||
"""Aggregate package stats from usage, active nodes, and disabled packs.
|
||||
|
||||
``disabled_packages`` is the set of directory names currently parked in
|
||||
``custom_nodes/.disabled``. Those packages are no longer loaded, so
|
||||
they cannot appear in the node mapper. Including them here keeps a
|
||||
never-used package visible long enough for the UI to offer Enable.
|
||||
"""
|
||||
node_stats = self.get_node_stats()
|
||||
|
||||
# Build per-package data from DB
|
||||
@@ -263,13 +269,33 @@ class UsageTracker:
|
||||
}
|
||||
packages[pkg]["total_nodes"] = total
|
||||
|
||||
# Packages only in DB (not in mapper) are uninstalled/disabled
|
||||
# Packages only in DB (not in mapper) are uninstalled/disabled.
|
||||
# Also add disabled-on-disk packages that have no recorded executions;
|
||||
# without this, a package disabled before first use disappears from the
|
||||
# UI and cannot be re-enabled there.
|
||||
active_package_keys = {pkg.lower() for pkg in node_counts}
|
||||
existing_package_keys = {pkg.lower() for pkg in packages}
|
||||
for pkg in disabled_packages:
|
||||
if not isinstance(pkg, str) or not pkg or pkg.lower() in active_package_keys:
|
||||
continue
|
||||
if pkg.lower() in existing_package_keys:
|
||||
continue
|
||||
packages[pkg] = {
|
||||
"package": pkg,
|
||||
"total_executions": 0,
|
||||
"used_nodes": 0,
|
||||
"nodes": [],
|
||||
"last_seen": None,
|
||||
"total_nodes": 0,
|
||||
}
|
||||
existing_package_keys.add(pkg.lower())
|
||||
|
||||
# node_counts already includes all packages from mapper + get_all_packages()
|
||||
installed_packages = set(node_counts.keys())
|
||||
installed_packages = active_package_keys
|
||||
for pkg, entry in packages.items():
|
||||
if "total_nodes" not in entry:
|
||||
entry["total_nodes"] = entry["used_nodes"]
|
||||
entry["installed"] = pkg in installed_packages
|
||||
entry["installed"] = pkg.lower() in installed_packages
|
||||
|
||||
# Classify packages by usage recency
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
Reference in New Issue
Block a user