feat(models): add bulk model deletion controls

This commit is contained in:
2026-07-29 19:45:53 +02:00
parent c43c8dde79
commit 8a71013247
5 changed files with 259 additions and 6 deletions
+96 -6
View File
@@ -258,6 +258,7 @@ async function showStatsDialog(initialTab = "nodes") {
wireWorkflowButtons(dialog);
wireWhitelistButtons(dialog);
wireNativeEnableButtons(dialog);
wireModelDeleteControls(dialog);
switchTab(TABS.includes(initialTab) ? initialTab : "nodes");
@@ -432,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, " ");
@@ -443,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.