/** * ModelSync UI Panel for ComfyUI * * Provides a user interface for: * - Viewing tracked models and their update status * - Configuring auto-update settings * - Managing sync targets * - Triggering manual syncs */ import { app } from "../../scripts/app.js"; class ModelSyncPanel { constructor() { this.element = null; this.models = []; this.targets = []; this.config = {}; this.currentTab = "models"; this.searchQuery = ""; this.filterStatus = "all"; // all, updates, auto this.menuButtonCreated = false; } async init() { // Load initial data await this.refreshData(); } async createMenuButton() { if (this.menuButtonCreated) return; const self = this; // Try to add new-style button (ComfyUI with Vue frontend) try { const { ComfyButton } = await import("../../scripts/ui/components/button.js"); const { ComfyButtonGroup } = await import("../../scripts/ui/components/buttonGroup.js"); if (app.menu?.settingsGroup) { const syncButton = new ComfyButton({ icon: "sync", action: () => self.toggle(), tooltip: "Model Sync - Track and sync model updates", content: "Model Sync", classList: "comfyui-button comfyui-menu-mobile-collapse" }); const syncGroup = new ComfyButtonGroup(syncButton.element); app.menu.settingsGroup.element.before(syncGroup.element); this.menuButtonCreated = true; console.log("[ModelSync] New-style menu button added"); return; } } catch (e) { console.log("[ModelSync] New-style menu not available:", e.message); } // Fall back to old-style button const tryCreateButton = () => { const menu = document.querySelector(".comfy-menu") || document.querySelector(".comfyui-menu"); if (!menu) { setTimeout(tryCreateButton, 500); return; } // Check if button already exists (either new-style or legacy) if (document.querySelector(".modelsync-menu-btn") || document.querySelector('[aria-label*="Model Sync"]')) { return; } const button = document.createElement("button"); button.textContent = "Model Sync"; button.classList.add("modelsync-menu-btn"); button.style.cssText = "margin: 4px; padding: 4px 8px;"; button.addEventListener("click", () => this.toggle()); // Insert into menu menu.appendChild(button); this.menuButtonCreated = true; console.log("[ModelSync] Legacy menu button added"); }; tryCreateButton(); } async refreshData() { try { const [modelsRes, targetsRes, configRes] = await Promise.all([ fetch("/modelsync/models"), fetch("/modelsync/targets"), fetch("/modelsync/config"), ]); if (modelsRes.ok) { const data = await modelsRes.json(); this.models = data.models || []; } if (targetsRes.ok) { const data = await targetsRes.json(); this.targets = data.targets || []; } if (configRes.ok) { this.config = await configRes.json(); } this.render(); } catch (error) { console.error("[ModelSync] Error refreshing data:", error); } } toggle() { if (this.element) { this.close(); } else { this.open(); } } open() { this.element = document.createElement("div"); this.element.classList.add("modelsync-panel"); this.element.innerHTML = this.getTemplate(); document.body.appendChild(this.element); this.attachEventListeners(); this.refreshData(); } close() { if (this.element) { this.element.remove(); this.element = null; } } getTemplate() { return `

Model Sync

`; } attachEventListeners() { if (!this.element) return; // Close button and overlay this.element.querySelectorAll("[data-action='close']").forEach((el) => { el.addEventListener("click", () => this.close()); }); // Tabs this.element.querySelectorAll(".modelsync-tab").forEach((tab) => { tab.addEventListener("click", () => { this.currentTab = tab.dataset.tab; this.updateTabs(); }); }); // Search const search = this.element.querySelector(".modelsync-search"); if (search) { search.addEventListener("input", (e) => { this.searchQuery = e.target.value.toLowerCase(); this.renderModelsList(); }); } // Filter const filter = this.element.querySelector(".modelsync-filter"); if (filter) { filter.addEventListener("change", (e) => { this.filterStatus = e.target.value; this.renderModelsList(); }); } // Action buttons this.element.querySelectorAll("[data-action]").forEach((btn) => { btn.addEventListener("click", () => this.handleAction(btn.dataset.action)); }); } async handleAction(action) { switch (action) { case "scan": await this.scanModels(); break; case "check": await this.checkUpdates(); break; case "add-target": this.showAddTargetDialog(); break; case "sync-all": await this.syncAll(); break; case "save-settings": await this.saveSettings(); break; } } async scanModels() { this.setStatus("Scanning models..."); try { const res = await fetch("/modelsync/models/scan", { method: "POST" }); const data = await res.json(); this.setStatus(`Scanned ${data.scanned} models. New: ${data.new}, Updated: ${data.updated}`); await this.refreshData(); } catch (error) { this.setStatus("Scan failed: " + error.message); } } async checkUpdates() { this.setStatus("Checking updates (hashing, finding sources, checking versions)..."); // Disable button during check const checkBtn = this.element?.querySelector('[data-action="check"]'); if (checkBtn) { checkBtn.disabled = true; checkBtn.textContent = "Checking..."; } try { const res = await fetch("/modelsync/check", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ concurrent: 5 }), }); const data = await res.json(); const parts = []; if (data.hashed > 0) parts.push(`${data.hashed} hashed`); if (data.sources_found > 0) parts.push(`${data.sources_found} sources found`); if (data.checked > 0) parts.push(`${data.checked} checked`); if (data.updates_available > 0) parts.push(`${data.updates_available} updates!`); if (data.errors > 0) parts.push(`${data.errors} errors`); const summary = parts.length > 0 ? parts.join(", ") : "No models to process"; this.setStatus(`Done: ${summary}`); await this.refreshData(); } catch (error) { this.setStatus("Check failed: " + error.message); } finally { if (checkBtn) { checkBtn.disabled = false; checkBtn.textContent = "Check Updates"; } } } async syncAll() { this.setStatus("Syncing to all targets..."); try { const res = await fetch("/modelsync/sync", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}), }); const data = await res.json(); const total = data.results.reduce((sum, r) => sum + r.models_synced, 0); this.setStatus(`Sync complete. ${total} models synced.`); } catch (error) { this.setStatus("Sync failed: " + error.message); } } async saveSettings() { const settings = { auto_update_enabled: this.element.querySelector("#auto-update-enabled").checked.toString(), check_interval_hours: this.element.querySelector("#check-interval").value, verify_downloads: this.element.querySelector("#verify-downloads").checked.toString(), backup_before_update: this.element.querySelector("#backup-before-update").checked.toString(), }; try { await fetch("/modelsync/config", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(settings), }); this.setStatus("Settings saved"); this.config = settings; } catch (error) { this.setStatus("Failed to save settings: " + error.message); } } showAddTargetDialog() { const name = prompt("Target name (e.g., 'frodo'):"); if (!name) return; const sshHost = prompt("SSH host (e.g., 'user@host' or empty for local):"); const modelPath = prompt("Models path on target:"); if (!modelPath) return; const maxVram = prompt("Max VRAM in MB (empty for no limit):"); this.addTarget({ name, ssh_host: sshHost || null, model_path: modelPath, max_vram_mb: maxVram ? parseInt(maxVram) : null, }); } async addTarget(target) { try { await fetch("/modelsync/targets", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(target), }); this.setStatus("Target added"); await this.refreshData(); } catch (error) { this.setStatus("Failed to add target: " + error.message); } } async deleteTarget(targetId) { if (!confirm("Delete this sync target?")) return; try { await fetch(`/modelsync/targets/${targetId}`, { method: "DELETE" }); this.setStatus("Target deleted"); await this.refreshData(); } catch (error) { this.setStatus("Failed to delete target: " + error.message); } } async syncToTarget(targetId) { this.setStatus("Syncing..."); try { const res = await fetch(`/modelsync/sync/${targetId}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}), }); const data = await res.json(); this.setStatus(`Synced ${data.models_synced} models to ${data.target}`); } catch (error) { this.setStatus("Sync failed: " + error.message); } } async toggleAutoUpdate(modelId, enabled) { try { await fetch(`/modelsync/models/${modelId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ auto_update: enabled ? 1 : 0 }), }); } catch (error) { console.error("[ModelSync] Failed to update model:", error); } } async downloadUpdate(modelId) { this.setStatus("Downloading update..."); try { const res = await fetch(`/modelsync/download/${modelId}`, { method: "POST" }); const data = await res.json(); this.setStatus(data.success ? "Download complete" : "Download failed"); await this.refreshData(); } catch (error) { this.setStatus("Download failed: " + error.message); } } updateTabs() { if (!this.element) return; this.element.querySelectorAll(".modelsync-tab").forEach((tab) => { tab.classList.toggle("active", tab.dataset.tab === this.currentTab); }); this.element.querySelectorAll(".modelsync-tab-content").forEach((content) => { content.classList.toggle("hidden", !content.id.includes(this.currentTab)); }); } render() { if (!this.element) return; this.renderModelsList(); this.renderTargetsList(); this.renderSettings(); } renderModelsList() { const container = this.element?.querySelector(".modelsync-models-list"); if (!container) return; let filtered = this.models; // Apply search filter if (this.searchQuery) { filtered = filtered.filter((m) => m.filename.toLowerCase().includes(this.searchQuery) ); } // Apply status filter if (this.filterStatus === "updates") { filtered = filtered.filter((m) => m.update_available); } else if (this.filterStatus === "auto") { filtered = filtered.filter((m) => m.auto_update); } if (filtered.length === 0) { container.innerHTML = '

No models found. Click "Scan" to discover models.

'; return; } container.innerHTML = ` ${filtered.map((m) => this.renderModelRow(m)).join("")}
Name Type Status Size Auto Actions
`; // Attach row event listeners container.querySelectorAll("[data-model-action]").forEach((el) => { el.addEventListener("click", () => { const action = el.dataset.modelAction; const modelId = parseInt(el.dataset.modelId); if (action === "download") { this.downloadUpdate(modelId); } else if (action === "toggle-auto") { const model = this.models.find((m) => m.id === modelId); this.toggleAutoUpdate(modelId, !model.auto_update); model.auto_update = !model.auto_update; this.renderModelsList(); } }); }); } renderModelRow(model) { const sizeStr = model.file_size ? (model.file_size / (1024 * 1024 * 1024)).toFixed(2) + " GB" : "Unknown"; // Determine status - check for truthy values (not null, not "null", not empty) const hasBeenChecked = model.last_checked && model.last_checked !== "null" && model.last_checked !== ""; const hasUpdate = model.update_available === 1 || model.update_available === true; const statusClass = hasUpdate ? "update" : hasBeenChecked ? "current" : "unknown"; const statusText = hasUpdate ? "Update Available" : hasBeenChecked ? "Up to Date" : "Not Checked"; return ` ${model.filename} ${model.model_type} ${statusText} ${sizeStr} ${ model.update_available ? `` : "" } `; } renderTargetsList() { const container = this.element?.querySelector(".modelsync-targets-list"); if (!container) return; if (this.targets.length === 0) { container.innerHTML = '

No sync targets configured.

'; return; } container.innerHTML = `
${this.targets.map((t) => this.renderTargetCard(t)).join("")}
`; // Attach target event listeners container.querySelectorAll("[data-target-action]").forEach((el) => { el.addEventListener("click", () => { const action = el.dataset.targetAction; const targetId = parseInt(el.dataset.targetId); if (action === "sync") { this.syncToTarget(targetId); } else if (action === "delete") { this.deleteTarget(targetId); } }); }); } renderTargetCard(target) { return `
${target.name} ${target.enabled ? "Enabled" : "Disabled"}

Host: ${target.ssh_host || "Local"}

Path: ${target.model_path}

Max VRAM: ${target.max_vram_mb ? target.max_vram_mb + " MB" : "No limit"}

Last Sync: ${target.last_sync || "Never"}

`; } renderSettings() { if (!this.element) return; const autoUpdate = this.element.querySelector("#auto-update-enabled"); const checkInterval = this.element.querySelector("#check-interval"); const verifyDownloads = this.element.querySelector("#verify-downloads"); const backupBefore = this.element.querySelector("#backup-before-update"); if (autoUpdate) { autoUpdate.checked = this.config.auto_update_enabled === "true"; } if (checkInterval) { checkInterval.value = this.config.check_interval_hours || "24"; } if (verifyDownloads) { verifyDownloads.checked = this.config.verify_downloads !== "false"; } if (backupBefore) { backupBefore.checked = this.config.backup_before_update !== "false"; } } setStatus(message) { const status = this.element?.querySelector(".modelsync-status"); if (status) { status.textContent = message; } } } // Create global instance const modelSyncPanel = new ModelSyncPanel(); // Load CSS function loadCSS() { if (document.querySelector('link[href*="modelsync.css"]')) return; const link = document.createElement("link"); link.rel = "stylesheet"; link.href = "/extensions/ComfyUI-ModelSync/modelsync.css"; document.head.appendChild(link); } // Register with ComfyUI's extension system app.registerExtension({ name: "ModelSync", async setup() { loadCSS(); // Create menu button using ComfyUI's proper API try { const { ComfyButton } = await import("../../scripts/ui/components/button.js"); const { ComfyButtonGroup } = await import("../../scripts/ui/components/buttonGroup.js"); const syncButton = new ComfyButton({ icon: "sync", action: () => modelSyncPanel.toggle(), tooltip: "Model Sync - Track and sync model updates", content: "Model Sync", classList: "comfyui-button comfyui-menu-mobile-collapse" }); const syncGroup = new ComfyButtonGroup(syncButton.element); app.menu?.settingsGroup?.element?.before(syncGroup.element); console.log("[ModelSync] Menu button added via extension setup"); } catch (e) { console.log("[ModelSync] New-style button not available:", e.message); // Fall back to legacy button modelSyncPanel.createMenuButton(); } // Initialize panel data await modelSyncPanel.init(); console.log("[ModelSync] Extension initialized"); } }); // Export for external access window.ModelSyncPanel = modelSyncPanel;