Files
Ben FinkleaandClaude Opus 4.5 0b6b9066b3 Fix multiple bugs: menu button visibility, source_hint handling, tempfile security
Bug fixes:
- Fix menu button not appearing: Use app.registerExtension() for proper
  ComfyUI initialization timing. The button now appears in the top bar
  next to the Manager button.
- Fix source_hint incorrectly saved as source_url: Pattern-matched hints
  like "Lightricks/LTX-Video" are not valid URLs and should not be used
  for update checks.
- Fix deprecated tempfile.mktemp(): Use secure mkstemp() instead to avoid
  race conditions.
- Add os import for file descriptor handling in update_scheduler.py

Test improvements:
- Update Playwright tests to hover over UI to reveal ComfyUI menu
- All 26 tests now passing (17 unit + 9 UI)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 14:46:47 -06:00

708 lines
22 KiB
JavaScript

/**
* 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 `
<div class="modelsync-overlay" data-action="close"></div>
<div class="modelsync-content">
<div class="modelsync-header">
<h2>Model Sync</h2>
<button class="modelsync-close" data-action="close">&times;</button>
</div>
<div class="modelsync-tabs">
<button class="modelsync-tab active" data-tab="models">Models</button>
<button class="modelsync-tab" data-tab="targets">Sync Targets</button>
<button class="modelsync-tab" data-tab="settings">Settings</button>
</div>
<div class="modelsync-body">
<div class="modelsync-tab-content" id="modelsync-models">
<div class="modelsync-toolbar">
<input type="text" class="modelsync-search" placeholder="Search models..." />
<select class="modelsync-filter">
<option value="all">All Models</option>
<option value="updates">Updates Available</option>
<option value="auto">Auto-Update Enabled</option>
</select>
<button class="modelsync-btn" data-action="scan">Scan</button>
<button class="modelsync-btn primary" data-action="check">Check Updates</button>
</div>
<div class="modelsync-models-list"></div>
</div>
<div class="modelsync-tab-content hidden" id="modelsync-targets">
<div class="modelsync-toolbar">
<button class="modelsync-btn primary" data-action="add-target">Add Target</button>
<button class="modelsync-btn" data-action="sync-all">Sync All</button>
</div>
<div class="modelsync-targets-list"></div>
</div>
<div class="modelsync-tab-content hidden" id="modelsync-settings">
<div class="modelsync-settings-form">
<div class="modelsync-setting">
<label>
<input type="checkbox" id="auto-update-enabled" />
Enable Auto-Update
</label>
<p class="modelsync-hint">Automatically download updates for enabled models</p>
</div>
<div class="modelsync-setting">
<label for="check-interval">Check Interval (hours)</label>
<input type="number" id="check-interval" min="1" max="168" value="24" />
</div>
<div class="modelsync-setting">
<label>
<input type="checkbox" id="verify-downloads" checked />
Verify Downloads
</label>
<p class="modelsync-hint">Verify file hash after downloading</p>
</div>
<div class="modelsync-setting">
<label>
<input type="checkbox" id="backup-before-update" checked />
Backup Before Update
</label>
<p class="modelsync-hint">Create .bak file before replacing model</p>
</div>
<button class="modelsync-btn primary" data-action="save-settings">Save Settings</button>
</div>
</div>
</div>
<div class="modelsync-footer">
<span class="modelsync-status"></span>
</div>
</div>
`;
}
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 = '<p class="modelsync-empty">No models found. Click "Scan" to discover models.</p>';
return;
}
container.innerHTML = `
<table class="modelsync-table">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Status</th>
<th>Size</th>
<th>Auto</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${filtered.map((m) => this.renderModelRow(m)).join("")}
</tbody>
</table>
`;
// 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 `
<tr>
<td title="${model.path}">${model.filename}</td>
<td>${model.model_type}</td>
<td><span class="modelsync-badge ${statusClass}">${statusText}</span></td>
<td>${sizeStr}</td>
<td>
<input type="checkbox" ${model.auto_update ? "checked" : ""}
data-model-action="toggle-auto" data-model-id="${model.id}" />
</td>
<td>
${
model.update_available
? `<button class="modelsync-btn small" data-model-action="download" data-model-id="${model.id}">Download</button>`
: ""
}
</td>
</tr>
`;
}
renderTargetsList() {
const container = this.element?.querySelector(".modelsync-targets-list");
if (!container) return;
if (this.targets.length === 0) {
container.innerHTML = '<p class="modelsync-empty">No sync targets configured.</p>';
return;
}
container.innerHTML = `
<div class="modelsync-targets">
${this.targets.map((t) => this.renderTargetCard(t)).join("")}
</div>
`;
// 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 `
<div class="modelsync-target-card">
<div class="modelsync-target-header">
<strong>${target.name}</strong>
<span class="modelsync-badge ${target.enabled ? "current" : "unknown"}">
${target.enabled ? "Enabled" : "Disabled"}
</span>
</div>
<div class="modelsync-target-details">
<p><strong>Host:</strong> ${target.ssh_host || "Local"}</p>
<p><strong>Path:</strong> ${target.model_path}</p>
<p><strong>Max VRAM:</strong> ${target.max_vram_mb ? target.max_vram_mb + " MB" : "No limit"}</p>
<p><strong>Last Sync:</strong> ${target.last_sync || "Never"}</p>
</div>
<div class="modelsync-target-actions">
<button class="modelsync-btn small primary" data-target-action="sync" data-target-id="${target.id}">Sync Now</button>
<button class="modelsync-btn small" data-target-action="delete" data-target-id="${target.id}">Delete</button>
</div>
</div>
`;
}
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;