feat: add Models tab with per-type usage stats
This commit is contained in:
+159
-57
@@ -34,23 +34,27 @@ app.registerExtension({
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function showStatsDialog() {
|
async function showStatsDialog() {
|
||||||
let data;
|
let data, modelData;
|
||||||
try {
|
try {
|
||||||
const resp = await fetch("/nodes-stats/packages");
|
const [pkgResp, modelResp] = await Promise.all([
|
||||||
if (!resp.ok) {
|
fetch("/nodes-stats/packages"),
|
||||||
alert("Failed to load node stats: HTTP " + resp.status);
|
fetch("/nodes-stats/models"),
|
||||||
return;
|
]);
|
||||||
}
|
if (!pkgResp.ok) { alert("Failed to load node stats: HTTP " + pkgResp.status); return; }
|
||||||
data = await resp.json();
|
if (!modelResp.ok) { alert("Failed to load model stats: HTTP " + modelResp.status); return; }
|
||||||
if (!Array.isArray(data)) {
|
data = await pkgResp.json();
|
||||||
alert("Failed to load node stats: unexpected response format");
|
modelData = await modelResp.json();
|
||||||
|
if (!Array.isArray(data) || !Array.isArray(modelData)) {
|
||||||
|
alert("Failed to load stats: unexpected response format");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert("Failed to load node stats: " + e.message);
|
alert("Failed to load stats: " + e.message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const custom = data.filter((p) => p.package !== "__builtin__");
|
||||||
|
|
||||||
// Remove existing dialog if any
|
// Remove existing dialog if any
|
||||||
const existing = document.getElementById("nodes-stats-dialog");
|
const existing = document.getElementById("nodes-stats-dialog");
|
||||||
if (existing) existing.remove();
|
if (existing) existing.remove();
|
||||||
@@ -67,66 +71,54 @@ async function showStatsDialog() {
|
|||||||
dialog.style.cssText =
|
dialog.style.cssText =
|
||||||
"background:#1e1e1e;color:#ddd;border-radius:8px;padding:24px;max-width:800px;width:90%;max-height:85vh;overflow-y:auto;font-family:monospace;font-size:13px;";
|
"background:#1e1e1e;color:#ddd;border-radius:8px;padding:24px;max-width:800px;width:90%;max-height:85vh;overflow-y:auto;font-family:monospace;font-size:13px;";
|
||||||
|
|
||||||
const custom = data.filter((p) => p.package !== "__builtin__");
|
|
||||||
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;justify-content:space-between;align-items:center;margin-bottom:16px;">
|
let html = `<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
|
||||||
<h2 style="margin:0;color:#fff;font-size:18px;">Node Package Stats</h2>
|
<h2 style="margin:0;color:#fff;font-size:18px;">Node Package Stats</h2>
|
||||||
<button id="nodes-stats-close" style="background:none;border:none;color:#888;font-size:20px;cursor:pointer;">×</button>
|
<button id="nodes-stats-close" style="background:none;border:none;color:#888;font-size:20px;cursor:pointer;">×</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
html += `<div style="display:flex;gap:10px;margin-bottom:20px;flex-wrap:wrap;">
|
// Tab switcher — wired via addEventListener after insertion, no onclick globals
|
||||||
<div style="background:#3a1a1a;padding:8px 14px;border-radius:4px;border-left:3px solid #e44;">
|
html += `
|
||||||
<span style="font-size:20px;font-weight:bold;color:#e44;">${safeToRemove.length}</span>
|
<div style="display:flex;gap:0;margin-bottom:20px;border-bottom:1px solid #333;">
|
||||||
<span style="color:#c99;margin-left:6px;">safe to remove</span>
|
<button id="ns-tab-nodes"
|
||||||
</div>
|
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;">
|
||||||
<div style="background:#2a2215;padding:8px 14px;border-radius:4px;border-left:3px solid #e90;">
|
Nodes
|
||||||
<span style="font-size:20px;font-weight:bold;color:#e90;">${considerRemoving.length}</span>
|
</button>
|
||||||
<span style="color:#ca8;margin-left:6px;">consider removing</span>
|
<button id="ns-tab-models"
|
||||||
</div>
|
style="background:none;border:none;border-bottom:2px solid transparent;color:#888;padding:8px 18px;cursor:pointer;font-family:monospace;font-size:13px;">
|
||||||
<div style="background:#1a1a2a;padding:8px 14px;border-radius:4px;border-left:3px solid #68f;">
|
Models
|
||||||
<span style="font-size:20px;font-weight:bold;color:#68f;">${unusedNew.length}</span>
|
</button>
|
||||||
<span style="color:#99b;margin-left:6px;">unused <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>`;
|
</div>`;
|
||||||
|
|
||||||
if (safeToRemove.length > 0) {
|
// Nodes tab content (existing content, wrapped)
|
||||||
html += sectionHeader("Safe to Remove", "Unused for 2+ months", "#e44");
|
html += `<div id="ns-content-nodes">`;
|
||||||
html += buildTable(safeToRemove, "safe_to_remove");
|
html += buildNodesTabContent(custom);
|
||||||
}
|
html += `</div>`;
|
||||||
|
|
||||||
if (considerRemoving.length > 0) {
|
// Models tab content
|
||||||
html += sectionHeader("Consider Removing", "Unused for 1-2 months", "#e90");
|
html += `<div id="ns-content-models" style="display:none;">`;
|
||||||
html += buildTable(considerRemoving, "consider_removing");
|
html += buildModelsTabContent(modelData);
|
||||||
}
|
html += `</div>`;
|
||||||
|
|
||||||
if (unusedNew.length > 0) {
|
|
||||||
html += sectionHeader("Recently Unused", "Unused for less than 1 month", "#68f");
|
|
||||||
html += buildTable(unusedNew, "unused_new");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (used.length > 0) {
|
|
||||||
html += sectionHeader("Used", "", "#4a4");
|
|
||||||
html += buildTable(used, "used");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (uninstalled.length > 0) {
|
|
||||||
html += sectionHeader("Uninstalled", "Previously tracked, no longer installed", "#555");
|
|
||||||
html += buildTable(uninstalled, "uninstalled");
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
||||||
|
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"));
|
||||||
|
|
||||||
document
|
document
|
||||||
.getElementById("nodes-stats-close")
|
.getElementById("nodes-stats-close")
|
||||||
.addEventListener("click", () => overlay.remove());
|
.addEventListener("click", () => overlay.remove());
|
||||||
@@ -165,6 +157,116 @@ async function showStatsDialog() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 <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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <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;
|
||||||
|
}
|
||||||
|
|
||||||
function sectionHeader(title, subtitle, color) {
|
function sectionHeader(title, subtitle, color) {
|
||||||
let html = `<h3 style="color:${color};margin:16px 0 8px;font-size:14px;">${title}`;
|
let html = `<h3 style="color:${color};margin:16px 0 8px;font-size:14px;">${title}`;
|
||||||
if (subtitle) html += ` <span style="color:#666;font-size:12px;font-weight:normal;">— ${subtitle}</span>`;
|
if (subtitle) html += ` <span style="color:#666;font-size:12px;font-weight:normal;">— ${subtitle}</span>`;
|
||||||
|
|||||||
Reference in New Issue
Block a user