2608 lines
102 KiB
TypeScript
2608 lines
102 KiB
TypeScript
/**
|
|
* Status Dot Feature - Health indicator
|
|
*/
|
|
|
|
import {
|
|
get,
|
|
getToolsStatus,
|
|
post,
|
|
resetIndex,
|
|
getWatcherStatus,
|
|
forceDeleteDb,
|
|
listDbBackups,
|
|
saveDbBackup,
|
|
restoreDbBackup,
|
|
vectorBackfill,
|
|
unloadVectorModels,
|
|
} from "../../api/client.js";
|
|
import { ENDPOINTS } from "../../api/endpoints.js";
|
|
import { APP_CONFIG } from "../../app/config.js";
|
|
import { comfyConfirm } from "../../app/dialogs.js";
|
|
import { comfyToast, recordToastHistory } from "../../app/toast.js";
|
|
import { t } from "../../app/i18n.js";
|
|
import {
|
|
getRuntimeEnrichmentState,
|
|
setRuntimeEnrichmentState,
|
|
} from "../../stores/runtimeEnrichmentState.js";
|
|
import { loadMajoorSettings } from "../../app/settings.js";
|
|
import { EVENTS } from "../../app/events.js";
|
|
|
|
const TOOL_CAPABILITIES = [
|
|
{
|
|
key: "exiftool",
|
|
labelKey: "tool.exiftool",
|
|
hintKey: "tool.exiftool.hint",
|
|
},
|
|
{
|
|
key: "ffprobe",
|
|
labelKey: "tool.ffprobe",
|
|
hintKey: "tool.ffprobe.hint",
|
|
},
|
|
];
|
|
|
|
let _maintenanceActive = false;
|
|
let _dbRestoreStatusHandler: any = null;
|
|
let _runtimeStatusHandler: any = null;
|
|
const STATUS_CACHE_KEY = "__MJR_STATUS_DOT_CACHE__";
|
|
const STATUS_POLL_STATE = {
|
|
ACTIVE: "active",
|
|
COOLDOWN: "cooldown",
|
|
IDLE: "idle",
|
|
};
|
|
const STATUS_POLL_INTERVALS_MS = {
|
|
[STATUS_POLL_STATE.ACTIVE]: 2_000,
|
|
[STATUS_POLL_STATE.COOLDOWN]: 10_000,
|
|
[STATUS_POLL_STATE.IDLE]: 60_000,
|
|
};
|
|
|
|
function setMaintenanceActive(active: any) {
|
|
_maintenanceActive = !!active;
|
|
try {
|
|
(globalThis as any)._mjrMaintenanceActive = _maintenanceActive;
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
|
|
function formatBytes(bytes: any) {
|
|
const n = Number(bytes);
|
|
if (!Number.isFinite(n) || n <= 0) return "0 B";
|
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
let value = n;
|
|
let idx = 0;
|
|
while (value >= 1024 && idx < units.length - 1) {
|
|
value /= 1024;
|
|
idx += 1;
|
|
}
|
|
const precision = value >= 100 ? 0 : value >= 10 ? 1 : 2;
|
|
return `${value.toFixed(precision)} ${units[idx]}`;
|
|
}
|
|
|
|
function setStatusLines(statusText: any, lines: any, footerText: any = null) {
|
|
if (!statusText) return;
|
|
statusText.replaceChildren();
|
|
|
|
const normalizedLines = Array.isArray(lines)
|
|
? lines.filter((l) => l != null && String(l).trim() !== "")
|
|
: [];
|
|
normalizedLines.forEach((line, index) => {
|
|
if (index > 0) statusText.appendChild(document.createElement("br"));
|
|
statusText.appendChild(document.createTextNode(String(line)));
|
|
});
|
|
|
|
if (footerText != null && String(footerText).trim() !== "") {
|
|
statusText.appendChild(document.createElement("br"));
|
|
const span = document.createElement("span");
|
|
span.style.fontSize = "11px";
|
|
span.style.opacity = "0.6";
|
|
span.textContent = String(footerText);
|
|
statusText.appendChild(span);
|
|
}
|
|
}
|
|
|
|
function appendStatusProgressBars(statusText: any, items: any[] = []) {
|
|
if (!statusText) return;
|
|
const rows = Array.isArray(items)
|
|
? items.filter((item) => item && String(item.label || "").trim())
|
|
: [];
|
|
if (!rows.length) return;
|
|
|
|
const wrap = document.createElement("div");
|
|
wrap.className = "mjr-status-progress-list";
|
|
wrap.style.cssText =
|
|
"margin-top:8px;display:flex;flex-direction:column;gap:5px;min-width:220px;";
|
|
|
|
for (const item of rows) {
|
|
const percent = Math.max(0, Math.min(100, Number(item.percent) || 0));
|
|
const row = document.createElement("div");
|
|
row.className = "mjr-status-progress-row";
|
|
row.style.cssText = "display:grid;grid-template-columns:minmax(72px,0.9fr) 1.8fr auto;gap:7px;align-items:center;";
|
|
|
|
const label = document.createElement("span");
|
|
label.className = "mjr-status-progress-label";
|
|
label.textContent = String(item.label || "");
|
|
label.style.cssText = "font-size:10px;opacity:0.72;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;";
|
|
|
|
const bar = document.createElement("div");
|
|
bar.className = "mjr-status-progress-bar";
|
|
bar.style.cssText =
|
|
"height:6px;border-radius:999px;background:rgba(255,255,255,0.12);overflow:hidden;";
|
|
|
|
const fill = document.createElement("div");
|
|
fill.className = "mjr-status-progress-fill";
|
|
fill.style.cssText = `height:100%;width:${percent}%;border-radius:999px;background:${item.color || "var(--mjr-status-info, #64B5F6)"};`;
|
|
bar.appendChild(fill);
|
|
|
|
const value = document.createElement("span");
|
|
value.className = "mjr-status-progress-value";
|
|
value.textContent = String(item.value || `${Math.round(percent)}%`);
|
|
value.style.cssText = "font-size:10px;opacity:0.72;white-space:nowrap;text-align:right;";
|
|
|
|
row.appendChild(label);
|
|
row.appendChild(bar);
|
|
row.appendChild(value);
|
|
wrap.appendChild(row);
|
|
}
|
|
|
|
statusText.appendChild(wrap);
|
|
}
|
|
|
|
function buildStatusProgressItems(counters: any, { totalAssets = 0, withWorkflows = 0, withGenerationData = 0 } = {}) {
|
|
const total = Math.max(0, Number(totalAssets || counters?.total_assets || 0) || 0);
|
|
if (total <= 0) return [];
|
|
const pct = (value: any, denominator = total) => {
|
|
const n = Math.max(0, Number(value || 0) || 0);
|
|
const d = Math.max(1, Number(denominator || 0) || 1);
|
|
return Math.round((n / d) * 100);
|
|
};
|
|
const images = Number(counters?.images || 0) || 0;
|
|
const videos = Number(counters?.videos || 0) || 0;
|
|
const audio = Number(counters?.audio || 0) || 0;
|
|
const model3d = Number(counters?.model3d || counters?.by_kind?.model3d || 0) || 0;
|
|
const enrichQueue = Math.max(0, Number(counters?.enrichment_queue_length || 0) || 0);
|
|
const enrichDenominator = Math.max(total, total + enrichQueue);
|
|
|
|
return [
|
|
{
|
|
label: "Images",
|
|
percent: pct(images),
|
|
value: `${images.toLocaleString()} / ${pct(images)}%`,
|
|
color: "var(--mjr-badge-image, #2196F3)",
|
|
},
|
|
{
|
|
label: "Videos",
|
|
percent: pct(videos),
|
|
value: `${videos.toLocaleString()} / ${pct(videos)}%`,
|
|
color: "var(--mjr-badge-video, #F44336)",
|
|
},
|
|
audio || model3d
|
|
? {
|
|
label: "Other",
|
|
percent: pct(audio + model3d),
|
|
value: `${(audio + model3d).toLocaleString()} / ${pct(audio + model3d)}%`,
|
|
color: "var(--mjr-status-browser, #26A69A)",
|
|
}
|
|
: null,
|
|
{
|
|
label: "Workflows",
|
|
percent: pct(withWorkflows),
|
|
value: `${Number(withWorkflows || 0).toLocaleString()} / ${pct(withWorkflows)}%`,
|
|
color: "var(--mjr-status-success, #4CAF50)",
|
|
},
|
|
{
|
|
label: "Gen data",
|
|
percent: pct(withGenerationData),
|
|
value: `${Number(withGenerationData || 0).toLocaleString()} / ${pct(withGenerationData)}%`,
|
|
color: "var(--mjr-status-warning, #FFA726)",
|
|
},
|
|
enrichQueue
|
|
? {
|
|
label: "Enrich queue",
|
|
percent: pct(enrichQueue, enrichDenominator),
|
|
value: `${enrichQueue.toLocaleString()} pending`,
|
|
color: "var(--mjr-status-info, #64B5F6)",
|
|
}
|
|
: null,
|
|
].filter(Boolean);
|
|
}
|
|
|
|
function setStatusWithHint(statusText: any, mainText: any, hintText: any) {
|
|
if (!statusText) return;
|
|
statusText.replaceChildren();
|
|
statusText.appendChild(document.createTextNode(String(mainText || "")));
|
|
statusText.appendChild(document.createElement("br"));
|
|
const span = document.createElement("span");
|
|
span.style.fontSize = "11px";
|
|
span.style.opacity = "0.7";
|
|
span.textContent = String(hintText || "");
|
|
statusText.appendChild(span);
|
|
}
|
|
|
|
function applyStatusHighlight(section: any, tone = "neutral", options: Record<string, any> = {}) {
|
|
if (!section) return;
|
|
const notifyToast = options?.toast !== false;
|
|
const map = {
|
|
neutral: {
|
|
bg: "var(--bg-color, #1a1a1a)",
|
|
border: "var(--border-color, #333)",
|
|
glow: "transparent",
|
|
},
|
|
info: {
|
|
bg: "linear-gradient(135deg, rgba(33,150,243,0.14) 0%, rgba(100,181,246,0.08) 100%)",
|
|
border: "rgba(33,150,243,0.55)",
|
|
glow: "rgba(33,150,243,0.15)",
|
|
},
|
|
success: {
|
|
bg: "linear-gradient(135deg, rgba(76,175,80,0.16) 0%, rgba(139,195,74,0.08) 100%)",
|
|
border: "rgba(76,175,80,0.55)",
|
|
glow: "rgba(76,175,80,0.16)",
|
|
},
|
|
warning: {
|
|
bg: "linear-gradient(135deg, rgba(255,167,38,0.18) 0%, rgba(255,193,7,0.08) 100%)",
|
|
border: "rgba(255,167,38,0.58)",
|
|
glow: "rgba(255,167,38,0.18)",
|
|
},
|
|
error: {
|
|
bg: "linear-gradient(135deg, rgba(244,67,54,0.18) 0%, rgba(239,83,80,0.08) 100%)",
|
|
border: "rgba(244,67,54,0.62)",
|
|
glow: "rgba(244,67,54,0.2)",
|
|
},
|
|
browser: {
|
|
bg: "linear-gradient(135deg, rgba(0,150,136,0.18) 0%, rgba(38,166,154,0.08) 100%)",
|
|
border: "rgba(0,150,136,0.58)",
|
|
glow: "rgba(0,150,136,0.18)",
|
|
},
|
|
workflow: {
|
|
bg: "linear-gradient(135deg, rgba(186,104,200,0.20) 0%, rgba(244,143,177,0.14) 100%)",
|
|
border: "rgba(216,127,186,0.62)",
|
|
glow: "rgba(198,120,194,0.22)",
|
|
},
|
|
};
|
|
const style = (map as Record<string, any>)[tone] || map.neutral;
|
|
const prevTone = String(section.dataset?.mjrStatusTone || "neutral");
|
|
section.style.background = style.bg;
|
|
section.style.borderColor = style.border;
|
|
section.style.boxShadow = `0 0 0 1px ${style.glow} inset`;
|
|
section.dataset.mjrStatusTone = tone;
|
|
|
|
// Toast only when status tone changes, throttled to avoid polling spam.
|
|
if (notifyToast && tone !== prevTone) {
|
|
const now = Date.now();
|
|
const lastAt = Number(section._mjrStatusToastAt || 0) || 0;
|
|
if (now - lastAt >= 4000) {
|
|
section._mjrStatusToastAt = now;
|
|
const messages = {
|
|
info: t("status.toast.info", "Index status: checking"),
|
|
success: t("status.toast.success", "Index status: ready"),
|
|
warning: t("status.toast.warning", "Index status: attention needed"),
|
|
error: t("status.toast.error", "Index status: error"),
|
|
browser: t("status.toast.browser", "Index status: browser scope"),
|
|
workflow: t("status.toast.workflow", "Index status: workflow scope"),
|
|
};
|
|
const level =
|
|
tone === "error"
|
|
? "error"
|
|
: tone === "warning"
|
|
? "warning"
|
|
: tone === "success"
|
|
? "success"
|
|
: "info";
|
|
try {
|
|
comfyToast((messages as Record<string, any>)[tone] || messages.info, level, 1800, { noHistory: true });
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function formatWatcherScopeLabel(scope: any) {
|
|
const s = String(scope || "").toLowerCase();
|
|
if (s === "all") return t("scope.allFull", "All (Inputs + Outputs)");
|
|
if (s === "input" || s === "inputs") return t("scope.input", "Inputs");
|
|
if (s === "custom") return t("scope.custom", "Browser");
|
|
if (s === "workflow") return t("scope.workflow", "Workflow");
|
|
return t("scope.output", "Outputs");
|
|
}
|
|
|
|
function formatWatcherLine(watcher: any, desiredScope = "") {
|
|
if (!watcher || typeof watcher !== "object") {
|
|
if (desiredScope) {
|
|
return t("status.watcher.disabledScoped", "Watcher: disabled ({scope})", {
|
|
scope: formatWatcherScopeLabel(desiredScope),
|
|
});
|
|
}
|
|
return t("status.watcher.disabled", "Watcher: disabled");
|
|
}
|
|
const enabled = !!watcher.enabled;
|
|
const scope = watcher.scope ? String(watcher.scope) : "";
|
|
if (!enabled) {
|
|
if (scope || desiredScope) {
|
|
const effective =
|
|
desiredScope && scope && scope !== desiredScope
|
|
? desiredScope
|
|
: scope || desiredScope;
|
|
return t("status.watcher.disabledScoped", "Watcher: disabled ({scope})", {
|
|
scope: formatWatcherScopeLabel(effective),
|
|
});
|
|
}
|
|
return t("status.watcher.disabled", "Watcher: disabled");
|
|
}
|
|
if (scope) {
|
|
return t("status.watcher.enabledScoped", `Watcher: enabled (${scope})`, {
|
|
scope: formatWatcherScopeLabel(scope),
|
|
});
|
|
}
|
|
return t("status.watcher.enabled", "Watcher: enabled");
|
|
}
|
|
|
|
function emitGlobalGridReload(reason = "status-action") {
|
|
try {
|
|
window?.dispatchEvent?.(
|
|
new CustomEvent("mjr:reload-grid", {
|
|
detail: { reason: String(reason || "status-action") },
|
|
}),
|
|
);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
|
|
function markGridDirty(reason = "status-action", detail = {}) {
|
|
try {
|
|
window?.dispatchEvent?.(
|
|
new CustomEvent("mjr-grid-dirty", {
|
|
detail: {
|
|
reason: String(reason || "status-action"),
|
|
...detail,
|
|
},
|
|
}),
|
|
);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
|
|
function requestOpenMessageHistory(reason = "") {
|
|
try {
|
|
window?.dispatchEvent?.(
|
|
new CustomEvent(EVENTS.OPEN_MESSAGE_HISTORY, {
|
|
detail: { reason: String(reason || "status-action") },
|
|
}),
|
|
);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
|
|
async function askKeepVectors(actionLabel = "") {
|
|
return await comfyConfirm(
|
|
t(
|
|
"dialog.vectorsReset.keepQuestion",
|
|
"Keep existing AI vectors?\n\nConfirm = keep vectors\nCancel = continue without vectors",
|
|
),
|
|
actionLabel || t("dialog.vectorsReset.title", "AI vectors"),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Create status indicator section
|
|
*/
|
|
export function createStatusIndicator(options: Record<string, any> = {}): any {
|
|
const getScanContext =
|
|
typeof options?.getScanContext === "function" ? options.getScanContext : null;
|
|
// Reset any stale local maintenance lock when panel is recreated.
|
|
// Runtime state is re-detected from backend diagnostics during polling.
|
|
setMaintenanceActive(false);
|
|
const section = document.createElement("div");
|
|
section.style.cssText =
|
|
"margin-bottom: 20px; padding: 12px; background: var(--bg-color, #1a1a1a); border-radius: 6px; border: 1px solid var(--border-color, #333); cursor: pointer; transition: all 0.2s;";
|
|
applyStatusHighlight(section, "info", { toast: false });
|
|
|
|
// Header container
|
|
const header = document.createElement("div");
|
|
header.style.cssText = "display: flex; align-items: center; gap: 10px; margin-bottom: 8px;";
|
|
|
|
// Status dot
|
|
const statusDot = document.createElement("span");
|
|
statusDot.id = "mjr-status-dot";
|
|
statusDot.style.cssText =
|
|
"width: 12px; height: 12px; border-radius: 50%; background: var(--mjr-status-info, #64B5F6); display: inline-block; cursor: pointer;";
|
|
|
|
// Title container
|
|
const titleSpan = document.createElement("span");
|
|
titleSpan.id = "mjr-status-title";
|
|
titleSpan.style.cssText = "font-weight: 500; font-size: 13px; cursor: pointer;";
|
|
|
|
const indicator = document.createElement("span");
|
|
indicator.id = "mjr-status-title-indicator";
|
|
indicator.style.marginRight = "6px";
|
|
indicator.textContent = "v";
|
|
|
|
titleSpan.appendChild(indicator);
|
|
titleSpan.appendChild(document.createTextNode(t("status.indexStatus", "Index Status")));
|
|
|
|
header.appendChild(statusDot);
|
|
header.appendChild(titleSpan);
|
|
|
|
// Body container
|
|
const body = document.createElement("div");
|
|
body.id = "mjr-status-body";
|
|
body.style.marginLeft = "22px";
|
|
|
|
// Status text
|
|
const statusText = document.createElement("div");
|
|
statusText.id = "mjr-status-text";
|
|
statusText.style.cssText = "font-size: 12px; opacity: 0.8;";
|
|
statusText.textContent = t("status.checking");
|
|
|
|
// Capabilities section
|
|
const capabilities = document.createElement("div");
|
|
capabilities.id = "mjr-status-capabilities";
|
|
capabilities.style.cssText =
|
|
"font-size: 11px; opacity: 0.75; margin-top: 10px; display: flex; gap: 6px; flex-wrap: wrap;";
|
|
capabilities.textContent = t("status.discoveringTools");
|
|
|
|
body.appendChild(statusText);
|
|
body.appendChild(capabilities);
|
|
|
|
const toolsStatus = document.createElement("div");
|
|
toolsStatus.id = "mjr-tools-status";
|
|
toolsStatus.style.cssText = "font-size: 11px; opacity: 0.7; margin-top: 10px;";
|
|
toolsStatus.textContent = t("status.toolStatusChecking", "Tool status: checking...");
|
|
body.appendChild(toolsStatus);
|
|
|
|
const backupRow = document.createElement("div");
|
|
backupRow.style.cssText =
|
|
"margin-top: 10px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap;";
|
|
|
|
const backupSelect = document.createElement("select");
|
|
backupSelect.style.cssText = `
|
|
min-width: 240px;
|
|
padding: 4px 8px;
|
|
font-size: 11px;
|
|
border-radius: 6px;
|
|
border: 1px solid rgba(255,255,255,0.2);
|
|
background: rgba(255,255,255,0.04);
|
|
color: inherit;
|
|
`;
|
|
backupSelect.title = t("status.dbBackupSelectHint", "Select a DB backup to restore");
|
|
let _hasBackupChoices = false;
|
|
let restoreDbBtn: any = null;
|
|
const setSingleOption = (text: any) => {
|
|
backupSelect.replaceChildren();
|
|
const opt = document.createElement("option");
|
|
opt.value = "";
|
|
opt.textContent = String(text || "");
|
|
opt.disabled = true;
|
|
opt.selected = true;
|
|
backupSelect.appendChild(opt);
|
|
_hasBackupChoices = false;
|
|
backupSelect.disabled = true;
|
|
if (restoreDbBtn) restoreDbBtn.disabled = true;
|
|
};
|
|
setSingleOption(t("status.dbBackupLoading", "Loading DB backups..."));
|
|
|
|
const refreshBackups = async (preferredName = "") => {
|
|
try {
|
|
const res = await listDbBackups();
|
|
if (!res?.ok) {
|
|
setSingleOption(t("status.dbBackupNone", "No DB backup found"));
|
|
return;
|
|
}
|
|
const items = Array.isArray(res?.data?.items) ? res.data.items : [];
|
|
if (!items.length) {
|
|
setSingleOption(t("status.dbBackupNone", "No DB backup found"));
|
|
return;
|
|
}
|
|
backupSelect.replaceChildren();
|
|
let matched = false;
|
|
for (const item of items) {
|
|
const name = String(item?.name || "");
|
|
if (!name) continue;
|
|
const mtime = Number(item?.mtime || 0);
|
|
const size = Number(item?.size_bytes || 0);
|
|
const stamp = mtime > 0 ? new Date(mtime * 1000).toLocaleString() : "";
|
|
const label = `${name}${stamp ? ` (${stamp}` : ""}${size > 0 ? `, ${formatBytes(size)}` : ""}${stamp ? ")" : ""}`;
|
|
const opt = document.createElement("option");
|
|
opt.value = name;
|
|
opt.textContent = label;
|
|
if (preferredName && preferredName === name) {
|
|
opt.selected = true;
|
|
matched = true;
|
|
}
|
|
backupSelect.appendChild(opt);
|
|
}
|
|
_hasBackupChoices = backupSelect.childNodes.length > 0;
|
|
backupSelect.disabled = !_hasBackupChoices;
|
|
if (_hasBackupChoices && !matched && backupSelect.childNodes[0]) {
|
|
backupSelect.value = String((backupSelect.childNodes[0] as HTMLOptionElement)?.value || "");
|
|
}
|
|
if (restoreDbBtn) restoreDbBtn.disabled = !_hasBackupChoices;
|
|
} catch {
|
|
setSingleOption(t("status.dbBackupNone", "No DB backup found"));
|
|
}
|
|
};
|
|
void refreshBackups();
|
|
backupRow.appendChild(backupSelect);
|
|
|
|
const saveDbBtn = document.createElement("button");
|
|
saveDbBtn.type = "button";
|
|
saveDbBtn.textContent = t("btn.dbSave", "Save DB");
|
|
saveDbBtn.title = t("status.dbSaveHint", "Save a DB snapshot into archive folder");
|
|
saveDbBtn.style.cssText = `
|
|
padding: 5px 10px;
|
|
font-size: 11px;
|
|
border-radius: 6px;
|
|
border: 1px solid rgba(120,200,255,0.35);
|
|
background: transparent;
|
|
color: inherit;
|
|
cursor: pointer;
|
|
`;
|
|
saveDbBtn.onclick = async (event) => {
|
|
event.stopPropagation();
|
|
setMaintenanceActive(true);
|
|
const original = saveDbBtn.textContent;
|
|
const originalRestoreDisabled = !!restoreDbBtn?.disabled;
|
|
saveDbBtn.disabled = true;
|
|
if (restoreDbBtn) restoreDbBtn.disabled = true;
|
|
saveDbBtn.textContent = t("btn.saving", "Saving...");
|
|
statusDot.style.background = "var(--mjr-status-info, #64B5F6)";
|
|
applyStatusHighlight(section, "info");
|
|
try {
|
|
const res = await saveDbBackup();
|
|
if (res?.ok) {
|
|
const savedName = String(res?.data?.name || "").trim();
|
|
const sizeBytes = Number(res?.data?.size_bytes || 0);
|
|
comfyToast(t("toast.dbSaveSuccess", "Database backup saved"), "success", 2200, {
|
|
history: {
|
|
title: t("btn.dbSave", "Save DB"),
|
|
detail: savedName
|
|
? `${savedName}${sizeBytes > 0 ? ` | ${formatBytes(sizeBytes)}` : ""}`
|
|
: t("toast.dbSaveSuccess", "Database backup saved"),
|
|
operation: "save_db",
|
|
source: savedName || "archive",
|
|
forceStore: true,
|
|
},
|
|
});
|
|
await refreshBackups(savedName);
|
|
} else {
|
|
comfyToast(
|
|
res?.error || t("toast.dbSaveFailed", "Failed to save DB backup"),
|
|
"error",
|
|
);
|
|
}
|
|
} catch (error: any) {
|
|
comfyToast(
|
|
error?.message || t("toast.dbSaveFailed", "Failed to save DB backup"),
|
|
"error",
|
|
);
|
|
} finally {
|
|
setMaintenanceActive(false);
|
|
saveDbBtn.disabled = false;
|
|
if (restoreDbBtn) restoreDbBtn.disabled = originalRestoreDisabled || !_hasBackupChoices;
|
|
saveDbBtn.textContent = original;
|
|
try {
|
|
const target = getScanContext ? getScanContext() : null;
|
|
await updateStatus(statusDot, statusText, capabilities, target, null, {
|
|
force: true,
|
|
});
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
};
|
|
backupRow.appendChild(saveDbBtn);
|
|
|
|
restoreDbBtn = document.createElement("button");
|
|
restoreDbBtn.type = "button";
|
|
restoreDbBtn.textContent = t("btn.dbRestore", "Restore");
|
|
restoreDbBtn.title = t("status.dbRestoreHint", "Restore selected DB backup");
|
|
restoreDbBtn.style.cssText = `
|
|
padding: 5px 10px;
|
|
font-size: 11px;
|
|
border-radius: 6px;
|
|
border: 1px solid rgba(255,180,80,0.35);
|
|
background: transparent;
|
|
color: inherit;
|
|
cursor: pointer;
|
|
`;
|
|
restoreDbBtn.onclick = async (event: any) => {
|
|
event.stopPropagation();
|
|
const selected = String(backupSelect.value || "");
|
|
if (!selected) {
|
|
comfyToast(t("toast.dbRestoreSelect", "Select a DB backup first"), "warning", 2000);
|
|
return;
|
|
}
|
|
const confirmed = await comfyConfirm(
|
|
`${t(
|
|
"dialog.dbRestore.confirm",
|
|
"Restore selected DB backup? Current DB will be replaced.",
|
|
)}\n\n${selected}`,
|
|
t("btn.dbRestore", "Restore DB"),
|
|
);
|
|
if (!confirmed) return;
|
|
comfyToast(t("toast.dbRestoreStarted", "DB restore started"), "info", 1800, {
|
|
history: {
|
|
trackId: "maintenance:restore_db",
|
|
title: t("btn.dbRestore", "Restore DB"),
|
|
detail: selected,
|
|
operation: "restore_db",
|
|
source: selected,
|
|
status: "started",
|
|
forceStore: true,
|
|
},
|
|
});
|
|
setMaintenanceActive(true);
|
|
const original = restoreDbBtn.textContent;
|
|
const originalSaveDisabled = !!saveDbBtn.disabled;
|
|
restoreDbBtn.disabled = true;
|
|
saveDbBtn.disabled = true;
|
|
backupSelect.disabled = true;
|
|
restoreDbBtn.textContent = t("btn.restoring", "Restoring...");
|
|
statusDot.style.background = "var(--mjr-status-warning, #FFA726)";
|
|
applyStatusHighlight(section, "warning");
|
|
try {
|
|
const res = await restoreDbBackup({ name: selected, useLatest: false });
|
|
if (res?.ok) {
|
|
await refreshBackups(String(res?.data?.name || selected || ""));
|
|
} else {
|
|
comfyToast(
|
|
res?.error || t("toast.dbRestoreFailed", "Failed to restore DB backup"),
|
|
"error",
|
|
);
|
|
}
|
|
} catch (error: any) {
|
|
comfyToast(
|
|
error?.message || t("toast.dbRestoreFailed", "Failed to restore DB backup"),
|
|
"error",
|
|
);
|
|
} finally {
|
|
setMaintenanceActive(false);
|
|
restoreDbBtn.disabled = !_hasBackupChoices;
|
|
saveDbBtn.disabled = originalSaveDisabled;
|
|
backupSelect.disabled = !_hasBackupChoices;
|
|
restoreDbBtn.textContent = original;
|
|
emitGlobalGridReload("db-restore");
|
|
try {
|
|
const target = getScanContext ? getScanContext() : null;
|
|
await updateStatus(statusDot, statusText, capabilities, target, null, {
|
|
force: true,
|
|
});
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
};
|
|
backupRow.appendChild(restoreDbBtn);
|
|
|
|
body.appendChild(backupRow);
|
|
|
|
const actionsRow = document.createElement("div");
|
|
actionsRow.style.cssText =
|
|
"margin-top: 10px; display: flex; justify-content: flex-end; gap: 8px; flex-wrap: wrap;";
|
|
|
|
const actionLog = document.createElement("div");
|
|
actionLog.style.cssText =
|
|
"margin-top: 8px; font-size: 11px; opacity: 0.9; line-height: 1.35; white-space: pre-wrap;";
|
|
actionLog.textContent = "";
|
|
|
|
const setActionLog = (message: any, tone = "neutral") => {
|
|
const text = String(message || "").trim();
|
|
actionLog.textContent = text;
|
|
if (!text) {
|
|
actionLog.style.color = "inherit";
|
|
actionLog.style.opacity = "0.9";
|
|
return;
|
|
}
|
|
if (tone === "error") {
|
|
actionLog.style.color = "var(--error-text-color, #ff8a80)";
|
|
actionLog.style.opacity = "1";
|
|
return;
|
|
}
|
|
if (tone === "success") {
|
|
actionLog.style.color = "var(--success-text-color, #a5d6a7)";
|
|
actionLog.style.opacity = "1";
|
|
return;
|
|
}
|
|
if (tone === "info") {
|
|
actionLog.style.color = "var(--input-text, #cfd8dc)";
|
|
actionLog.style.opacity = "0.95";
|
|
return;
|
|
}
|
|
actionLog.style.color = "inherit";
|
|
actionLog.style.opacity = "0.9";
|
|
};
|
|
|
|
let memoryPurgeBtn: HTMLButtonElement;
|
|
|
|
const resetBtn = document.createElement("button");
|
|
resetBtn.type = "button";
|
|
resetBtn.textContent = t("btn.resetIndex");
|
|
resetBtn.title = t(
|
|
"status.resetIndexHint",
|
|
"Reset index cache (requires allowResetIndex in settings).",
|
|
);
|
|
resetBtn.style.cssText = `
|
|
padding: 5px 12px;
|
|
font-size: 11px;
|
|
border-radius: 6px;
|
|
border: 1px solid rgba(255,255,255,0.25);
|
|
background: transparent;
|
|
color: inherit;
|
|
cursor: pointer;
|
|
transition: border 0.2s, background 0.2s;
|
|
`;
|
|
resetBtn.onmouseenter = () => {
|
|
resetBtn.style.borderColor = "rgba(255,255,255,0.6)";
|
|
resetBtn.style.background = "rgba(255,255,255,0.08)";
|
|
};
|
|
resetBtn.onmouseleave = () => {
|
|
resetBtn.style.borderColor = "rgba(255,255,255,0.25)";
|
|
resetBtn.style.background = "transparent";
|
|
};
|
|
resetBtn.onclick = async (event) => {
|
|
event.stopPropagation();
|
|
const preserveVectors = await askKeepVectors(t("btn.resetIndex", "Reset index"));
|
|
const confirmed = await comfyConfirm(
|
|
preserveVectors
|
|
? t(
|
|
"dialog.resetIndex.confirmKeepVectors",
|
|
"This will reset index data and rescan files while keeping existing AI vectors.\n\nContinue?",
|
|
)
|
|
: t(
|
|
"dialog.resetIndex.msg",
|
|
"This will delete the database and rescan all files. Continue?",
|
|
),
|
|
t("dialog.resetIndex.title", "Reset index?"),
|
|
);
|
|
if (!confirmed) return;
|
|
requestOpenMessageHistory("reset-index");
|
|
setMaintenanceActive(true);
|
|
|
|
const originalText = resetBtn.textContent;
|
|
resetBtn.disabled = true;
|
|
memoryPurgeBtn.disabled = true;
|
|
resetBtn.textContent = t("btn.resetting");
|
|
|
|
// Status indicator feedback
|
|
statusDot.style.background = "var(--mjr-status-info, #64B5F6)";
|
|
applyStatusHighlight(section, "info");
|
|
|
|
try {
|
|
const ctx = getScanContext ? getScanContext() : {};
|
|
const scope = String(ctx?.scope || "output").toLowerCase();
|
|
const customRootId = ctx?.customRootId || ctx?.custom_root_id || ctx?.root_id || null;
|
|
const isCustomBrowserMode = scope === "custom" && !customRootId;
|
|
const isAll = scope === "all";
|
|
const effectiveScope = isCustomBrowserMode ? "all" : scope;
|
|
|
|
const res = await resetIndex({
|
|
scope: effectiveScope,
|
|
customRootId: isCustomBrowserMode ? null : customRootId,
|
|
reindex: true,
|
|
maintenance_force: true,
|
|
hard_reset_db: isAll && !preserveVectors,
|
|
clear_scan_journal: true,
|
|
clear_metadata_cache: true,
|
|
clear_asset_metadata: true,
|
|
clear_assets: !preserveVectors,
|
|
preserve_vectors: preserveVectors,
|
|
rebuild_fts: true,
|
|
});
|
|
if (res?.ok) {
|
|
statusDot.style.background = "var(--mjr-status-success, #4CAF50)";
|
|
applyStatusHighlight(section, "success");
|
|
} else {
|
|
statusDot.style.background = "var(--mjr-status-error, #f44336)";
|
|
applyStatusHighlight(section, "error");
|
|
}
|
|
} catch {
|
|
statusDot.style.background = "var(--mjr-status-error, #f44336)";
|
|
applyStatusHighlight(section, "error");
|
|
} finally {
|
|
setMaintenanceActive(false);
|
|
resetBtn.disabled = false;
|
|
memoryPurgeBtn.disabled = false;
|
|
resetBtn.textContent = originalText;
|
|
emitGlobalGridReload("index-reset");
|
|
try {
|
|
const target = getScanContext ? getScanContext() : null;
|
|
await updateStatus(statusDot, statusText, capabilities, target, null, {
|
|
force: true,
|
|
});
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
};
|
|
|
|
const backfillBtn = document.createElement("button");
|
|
backfillBtn.type = "button";
|
|
backfillBtn.textContent = "Backfill vectors";
|
|
backfillBtn.title = "Compute missing AI vector embeddings for existing assets.";
|
|
backfillBtn.style.cssText = `
|
|
padding: 5px 12px;
|
|
font-size: 11px;
|
|
border-radius: 6px;
|
|
border: 1px solid rgba(0, 188, 212, 0.45);
|
|
background: transparent;
|
|
color: inherit;
|
|
cursor: pointer;
|
|
transition: border 0.2s, background 0.2s;
|
|
`;
|
|
backfillBtn.onmouseenter = () => {
|
|
backfillBtn.style.borderColor = "rgba(0, 188, 212, 0.8)";
|
|
backfillBtn.style.background = "rgba(0, 188, 212, 0.12)";
|
|
};
|
|
backfillBtn.onmouseleave = () => {
|
|
backfillBtn.style.borderColor = "rgba(0, 188, 212, 0.45)";
|
|
backfillBtn.style.background = "transparent";
|
|
};
|
|
backfillBtn.onclick = async (event) => {
|
|
event.stopPropagation();
|
|
const ctx = getScanContext ? getScanContext() : {};
|
|
const desiredScope = String(ctx?.scope || "output").toLowerCase();
|
|
const desiredCustomRootId = String(
|
|
ctx?.customRootId || ctx?.custom_root_id || ctx?.root_id || "",
|
|
).trim();
|
|
const isCustomBrowserMode = desiredScope === "custom" && !desiredCustomRootId;
|
|
if (isCustomBrowserMode) {
|
|
comfyToast(
|
|
t(
|
|
"status.customBrowserScanDisabledHint",
|
|
"Use Outputs, Inputs, or All to run indexing scans",
|
|
),
|
|
"warning",
|
|
2600,
|
|
);
|
|
setActionLog(
|
|
"Backfill skipped - Browser scope has no selected custom root.",
|
|
"warning",
|
|
);
|
|
return;
|
|
}
|
|
const backfillScope = desiredScope || "output";
|
|
const backfillScopeLabel = formatWatcherScopeLabel(backfillScope);
|
|
requestOpenMessageHistory("vector-backfill");
|
|
const historyBase = {
|
|
history: {
|
|
trackId: `vector-backfill:status:${backfillScope}:${desiredCustomRootId || "default"}`,
|
|
title: "Vector Backfill",
|
|
source: backfillScopeLabel,
|
|
operation: "vector_backfill",
|
|
forceStore: true,
|
|
},
|
|
};
|
|
setMaintenanceActive(true);
|
|
|
|
const originalBackfillText = backfillBtn.textContent;
|
|
const originalResetText = resetBtn.textContent;
|
|
backfillBtn.disabled = true;
|
|
resetBtn.disabled = true;
|
|
memoryPurgeBtn.disabled = true;
|
|
backfillBtn.textContent = "Backfilling...";
|
|
setActionLog(`Backfill started (${backfillScopeLabel})...`, "info");
|
|
recordToastHistory(
|
|
{ summary: "Vector Backfill", detail: `Started (${backfillScopeLabel})` },
|
|
"info",
|
|
0,
|
|
{
|
|
history: {
|
|
...historyBase.history,
|
|
status: "started",
|
|
detail: `Started (${backfillScopeLabel})`,
|
|
},
|
|
},
|
|
);
|
|
|
|
statusDot.style.background = "var(--mjr-status-info, #64B5F6)";
|
|
applyStatusHighlight(section, "info");
|
|
try {
|
|
const backfillOptions: Record<string, any> = {
|
|
scope: backfillScope,
|
|
onProgress: (payload: any) => {
|
|
const status = String(payload?.status || "").toLowerCase();
|
|
const progress = payload?.progress || payload?.result || {};
|
|
const candidates = Number(progress?.candidates || progress?.processed || 0);
|
|
const candidateTotal = Number(progress?.candidate_total || candidates || 0);
|
|
const totalAssets = Number(
|
|
progress?.eligible_total || progress?.total_assets || 0,
|
|
);
|
|
const indexed = Number(progress?.indexed || 0);
|
|
const skipped = Number(progress?.skipped || 0);
|
|
const errors = Number(progress?.errors || 0);
|
|
if (status === "queued") {
|
|
setActionLog("Backfill queued...", "info");
|
|
recordToastHistory(
|
|
{
|
|
summary: "Vector Backfill",
|
|
detail: `Queued (${backfillScopeLabel})`,
|
|
},
|
|
"info",
|
|
0,
|
|
{
|
|
history: {
|
|
...historyBase.history,
|
|
status: "queued",
|
|
detail: `Queued (${backfillScopeLabel})`,
|
|
progress: { current: 0, total: 1, percent: 0, label: "queued" },
|
|
},
|
|
},
|
|
);
|
|
return;
|
|
}
|
|
if (
|
|
status === "running" ||
|
|
candidates > 0 ||
|
|
candidateTotal > 0 ||
|
|
totalAssets > 0 ||
|
|
indexed > 0 ||
|
|
skipped > 0 ||
|
|
errors > 0
|
|
) {
|
|
const detailText =
|
|
totalAssets > 0
|
|
? `Indexed ${indexed}/${totalAssets} assets, candidates ${candidateTotal}, skipped ${skipped}, errors ${errors}`
|
|
: `Candidates ${candidates}, indexed ${indexed}, skipped ${skipped}, errors ${errors}`;
|
|
setActionLog(
|
|
totalAssets > 0
|
|
? `Backfill running - indexed ${indexed}/${totalAssets} assets, candidates ${candidateTotal}, skipped ${skipped}, errors ${errors}`
|
|
: `Backfill running - candidates ${candidates}, indexed ${indexed}, skipped ${skipped}, errors ${errors}`,
|
|
"info",
|
|
);
|
|
recordToastHistory(
|
|
{ summary: "Vector Backfill", detail: detailText },
|
|
"info",
|
|
0,
|
|
{
|
|
history: {
|
|
...historyBase.history,
|
|
status: "running",
|
|
detail: detailText,
|
|
progress: {
|
|
current:
|
|
totalAssets > 0 ? indexed : indexed + skipped + errors,
|
|
total:
|
|
totalAssets > 0
|
|
? totalAssets
|
|
: Math.max(candidates, indexed + skipped + errors),
|
|
percent:
|
|
totalAssets > 0
|
|
? Math.round((indexed / totalAssets) * 100)
|
|
: Math.max(candidates, indexed + skipped + errors) >
|
|
0
|
|
? Math.round(
|
|
((indexed + skipped + errors) /
|
|
Math.max(
|
|
candidates,
|
|
indexed + skipped + errors,
|
|
)) *
|
|
100,
|
|
)
|
|
: null,
|
|
eligible_total: totalAssets > 0 ? totalAssets : undefined,
|
|
candidate_total:
|
|
candidateTotal > 0 ? candidateTotal : undefined,
|
|
indexed,
|
|
skipped,
|
|
errors,
|
|
label: "running",
|
|
},
|
|
},
|
|
},
|
|
);
|
|
}
|
|
},
|
|
};
|
|
if (backfillScope === "custom" && desiredCustomRootId) {
|
|
backfillOptions.customRootId = desiredCustomRootId;
|
|
}
|
|
const res = await vectorBackfill(64, backfillOptions);
|
|
if (res?.ok) {
|
|
const state = String(res?.data?.status || "").toLowerCase();
|
|
const pending =
|
|
!!res?.data?.pending || ["queued", "running", "pending"].includes(state);
|
|
const progress = res?.data?.progress || {};
|
|
const processed = Number(res?.data?.processed ?? progress?.candidates ?? 0);
|
|
const indexed = Number(res?.data?.indexed ?? progress?.indexed ?? 0);
|
|
const skipped = Number(res?.data?.skipped ?? progress?.skipped ?? 0);
|
|
const errors = Number(res?.data?.errors ?? progress?.errors ?? 0);
|
|
if (pending) {
|
|
const backfillId = String(res?.data?.backfill_id || "").trim();
|
|
const msg = t(
|
|
"toast.vectorBackfillRunning",
|
|
"Vector backfill still running in background{job}.",
|
|
{ job: backfillId ? ` (${backfillId.slice(0, 8)})` : "" },
|
|
);
|
|
comfyToast(msg, "info", 4200, {
|
|
history: {
|
|
...historyBase.history,
|
|
status: "running",
|
|
detail: `Running in background${backfillId ? ` (${backfillId.slice(0, 8)})` : ""}.`,
|
|
progress: {
|
|
current: indexed + skipped + errors,
|
|
total: Math.max(processed, indexed + skipped + errors),
|
|
percent:
|
|
Math.max(processed, indexed + skipped + errors) > 0
|
|
? Math.round(
|
|
((indexed + skipped + errors) /
|
|
Math.max(processed, indexed + skipped + errors)) *
|
|
100,
|
|
)
|
|
: null,
|
|
indexed,
|
|
skipped,
|
|
errors,
|
|
label: "running",
|
|
},
|
|
},
|
|
});
|
|
setActionLog(
|
|
`Backfill running in background - candidates ${processed}, indexed ${indexed}, skipped ${skipped}, errors ${errors}`,
|
|
"info",
|
|
);
|
|
statusDot.style.background = "var(--mjr-status-info, #64B5F6)";
|
|
applyStatusHighlight(section, "info");
|
|
} else {
|
|
comfyToast(
|
|
t(
|
|
"toast.vectorBackfillComplete",
|
|
"Vector backfill complete! Processed: {processed}, Indexed: {indexed}, Skipped: {skipped}",
|
|
{ processed, indexed, skipped },
|
|
),
|
|
"success",
|
|
3000,
|
|
{
|
|
history: {
|
|
...historyBase.history,
|
|
status: "succeeded",
|
|
detail: `Processed ${processed}, indexed ${indexed}, skipped ${skipped}`,
|
|
progress: {
|
|
current: processed,
|
|
total: processed,
|
|
percent: processed > 0 ? 100 : null,
|
|
indexed,
|
|
skipped,
|
|
errors,
|
|
label: "done",
|
|
},
|
|
},
|
|
},
|
|
);
|
|
setActionLog(
|
|
`Backfill OK - processed ${processed}, indexed ${indexed}, skipped ${skipped}`,
|
|
"success",
|
|
);
|
|
statusDot.style.background = "var(--mjr-status-success, #4CAF50)";
|
|
applyStatusHighlight(section, "success");
|
|
}
|
|
} else {
|
|
const err = String(
|
|
res?.error || t("toast.vectorBackfillFailedGeneric", "Backfill failed"),
|
|
);
|
|
const code = String(res?.code || "").trim();
|
|
const status = Number(res?.status || 0) || 0;
|
|
const detail = [code ? `code=${code}` : "", status ? `status=${status}` : "", err]
|
|
.filter(Boolean)
|
|
.join(" | ");
|
|
comfyToast(detail, "error", 4500, {
|
|
history: {
|
|
...historyBase.history,
|
|
status: "failed",
|
|
detail,
|
|
progress: { label: "failed", errors: 1 },
|
|
},
|
|
});
|
|
setActionLog(`Backfill ? - ${detail}\nSee console for full payload.`, "error");
|
|
console.error("[Majoor] Vector backfill failed response", res);
|
|
statusDot.style.background = "var(--mjr-status-error, #f44336)";
|
|
applyStatusHighlight(section, "error");
|
|
}
|
|
} catch (error: any) {
|
|
const errText = String(error?.message || error || "Backfill failed");
|
|
comfyToast(errText, "error", 4500, {
|
|
history: {
|
|
...historyBase.history,
|
|
status: "failed",
|
|
detail: errText,
|
|
progress: { label: "failed", errors: 1 },
|
|
},
|
|
});
|
|
setActionLog(`Backfill EXCEPTION - ${errText}\nSee console for stack trace.`, "error");
|
|
console.error("[Majoor] Vector backfill exception", error);
|
|
statusDot.style.background = "var(--mjr-status-error, #f44336)";
|
|
applyStatusHighlight(section, "error");
|
|
} finally {
|
|
setMaintenanceActive(false);
|
|
backfillBtn.disabled = false;
|
|
resetBtn.disabled = false;
|
|
memoryPurgeBtn.disabled = false;
|
|
backfillBtn.textContent = originalBackfillText;
|
|
resetBtn.textContent = originalResetText;
|
|
emitGlobalGridReload("vector-backfill");
|
|
try {
|
|
const target = getScanContext ? getScanContext() : null;
|
|
await updateStatus(statusDot, statusText, capabilities, target, null, {
|
|
force: true,
|
|
});
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
};
|
|
|
|
memoryPurgeBtn = document.createElement("button");
|
|
memoryPurgeBtn.type = "button";
|
|
memoryPurgeBtn.textContent = t("btn.memoryPurge", "Memory purge");
|
|
memoryPurgeBtn.title = t(
|
|
"tooltip.memoryPurge",
|
|
"Unload Majoor AI models, ask ComfyUI to unload loaded models, and clear torch cache when idle.",
|
|
);
|
|
memoryPurgeBtn.style.cssText = `
|
|
padding: 5px 12px;
|
|
font-size: 11px;
|
|
border-radius: 6px;
|
|
border: 1px solid rgba(156, 204, 101, 0.45);
|
|
background: transparent;
|
|
color: inherit;
|
|
cursor: pointer;
|
|
transition: border 0.2s, background 0.2s;
|
|
`;
|
|
memoryPurgeBtn.onmouseenter = () => {
|
|
memoryPurgeBtn.style.borderColor = "rgba(156, 204, 101, 0.85)";
|
|
memoryPurgeBtn.style.background = "rgba(156, 204, 101, 0.12)";
|
|
};
|
|
memoryPurgeBtn.onmouseleave = () => {
|
|
memoryPurgeBtn.style.borderColor = "rgba(156, 204, 101, 0.45)";
|
|
memoryPurgeBtn.style.background = "transparent";
|
|
};
|
|
memoryPurgeBtn.onclick = async (event) => {
|
|
event.stopPropagation();
|
|
const originalText = memoryPurgeBtn.textContent;
|
|
memoryPurgeBtn.disabled = true;
|
|
backfillBtn.disabled = true;
|
|
resetBtn.disabled = true;
|
|
memoryPurgeBtn.textContent = t("btn.memoryPurging", "Purging...");
|
|
setActionLog(t("status.memoryPurgeStarted", "Memory purge started..."), "info");
|
|
statusDot.style.background = "var(--mjr-status-info, #64B5F6)";
|
|
applyStatusHighlight(section, "info");
|
|
try {
|
|
const res = await unloadVectorModels();
|
|
if (res?.ok) {
|
|
const comfyPurged = !!res?.data?.comfy_models;
|
|
const msg = comfyPurged
|
|
? t(
|
|
"toast.memoryPurgeComplete",
|
|
"Memory purge complete. Majoor AI and ComfyUI model caches were released.",
|
|
)
|
|
: t(
|
|
"toast.memoryPurgeCompleteMajoorOnly",
|
|
"Memory purge complete. Majoor AI caches were released.",
|
|
);
|
|
comfyToast(msg, "success", 3200);
|
|
setActionLog(msg, "success");
|
|
statusDot.style.background = "var(--mjr-status-success, #4CAF50)";
|
|
applyStatusHighlight(section, "success");
|
|
} else {
|
|
const err = String(res?.error || "Memory purge failed");
|
|
comfyToast(err, "error", 4500);
|
|
setActionLog(err, "error");
|
|
statusDot.style.background = "var(--mjr-status-error, #f44336)";
|
|
applyStatusHighlight(section, "error");
|
|
}
|
|
} catch (error: any) {
|
|
const err = String(error?.message || error || "Memory purge failed");
|
|
comfyToast(err, "error", 4500);
|
|
setActionLog(err, "error");
|
|
statusDot.style.background = "var(--mjr-status-error, #f44336)";
|
|
applyStatusHighlight(section, "error");
|
|
} finally {
|
|
memoryPurgeBtn.disabled = false;
|
|
backfillBtn.disabled = false;
|
|
resetBtn.disabled = false;
|
|
memoryPurgeBtn.textContent = originalText;
|
|
try {
|
|
const target = getScanContext ? getScanContext() : null;
|
|
await updateStatus(statusDot, statusText, capabilities, target, null, {
|
|
force: true,
|
|
});
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
};
|
|
|
|
actionsRow.appendChild(backfillBtn);
|
|
actionsRow.appendChild(memoryPurgeBtn);
|
|
actionsRow.appendChild(resetBtn);
|
|
body.appendChild(actionLog);
|
|
|
|
const deleteDbBtn = document.createElement("button");
|
|
deleteDbBtn.type = "button";
|
|
deleteDbBtn.textContent = t("btn.deleteDb");
|
|
deleteDbBtn.title = t("tooltip.deleteDb", "Force-delete database and rebuild from scratch");
|
|
deleteDbBtn.style.cssText = `
|
|
padding: 5px 12px;
|
|
font-size: 11px;
|
|
border-radius: 6px;
|
|
border: 1px solid rgba(255,80,80,0.4);
|
|
background: transparent;
|
|
color: inherit;
|
|
cursor: pointer;
|
|
transition: border 0.2s, background 0.2s;
|
|
`;
|
|
deleteDbBtn.onmouseenter = () => {
|
|
deleteDbBtn.style.borderColor = "rgba(255,80,80,0.8)";
|
|
deleteDbBtn.style.background = "rgba(255,80,80,0.12)";
|
|
};
|
|
deleteDbBtn.onmouseleave = () => {
|
|
deleteDbBtn.style.borderColor = "rgba(255,80,80,0.4)";
|
|
deleteDbBtn.style.background = "transparent";
|
|
};
|
|
deleteDbBtn.onclick = async (event) => {
|
|
event.stopPropagation();
|
|
const preserveVectors = await askKeepVectors(t("btn.deleteDb", "Delete DB"));
|
|
const confirmed = await comfyConfirm(
|
|
preserveVectors
|
|
? t(
|
|
"dialog.dbDelete.keepVectorsConfirm",
|
|
"This will reset index data and keep existing AI vectors. Database files will not be force-deleted.\n\nContinue?",
|
|
)
|
|
: t(
|
|
"dialog.dbDelete.confirm",
|
|
"This will permanently delete the index database and rebuild it from scratch. All ratings, tags, and cached metadata will be lost.\n\nContinue?",
|
|
),
|
|
t("btn.deleteDb", "Delete DB"),
|
|
);
|
|
if (!confirmed) return;
|
|
setMaintenanceActive(true);
|
|
|
|
const originalText = deleteDbBtn.textContent;
|
|
deleteDbBtn.disabled = true;
|
|
deleteDbBtn.textContent = preserveVectors
|
|
? t("btn.resetting", "Resetting...")
|
|
: t("btn.deletingDb");
|
|
resetBtn.disabled = true;
|
|
memoryPurgeBtn.disabled = true;
|
|
|
|
statusDot.style.background = "var(--mjr-status-info, #64B5F6)";
|
|
applyStatusHighlight(section, "info");
|
|
|
|
try {
|
|
const res = preserveVectors
|
|
? await resetIndex({
|
|
scope: "all",
|
|
reindex: true,
|
|
maintenance_force: true,
|
|
hard_reset_db: false,
|
|
clear_scan_journal: true,
|
|
clear_metadata_cache: true,
|
|
clear_asset_metadata: true,
|
|
clear_assets: false,
|
|
preserve_vectors: true,
|
|
rebuild_fts: true,
|
|
})
|
|
: await forceDeleteDb();
|
|
if (res?.ok) {
|
|
(globalThis as any)._mjrCorruptToastShown = false;
|
|
statusDot.style.background = "var(--mjr-status-success, #4CAF50)";
|
|
applyStatusHighlight(section, "success");
|
|
} else {
|
|
statusDot.style.background = "var(--mjr-status-error, #f44336)";
|
|
applyStatusHighlight(section, "error");
|
|
}
|
|
} catch {
|
|
statusDot.style.background = "var(--mjr-status-error, #f44336)";
|
|
applyStatusHighlight(section, "error");
|
|
} finally {
|
|
setMaintenanceActive(false);
|
|
deleteDbBtn.disabled = false;
|
|
deleteDbBtn.textContent = originalText;
|
|
resetBtn.disabled = false;
|
|
memoryPurgeBtn.disabled = false;
|
|
emitGlobalGridReload(
|
|
preserveVectors ? "index-reset-preserve-vectors" : "db-force-delete",
|
|
);
|
|
try {
|
|
const target = getScanContext ? getScanContext() : null;
|
|
await updateStatus(statusDot, statusText, capabilities, target, null, {
|
|
force: true,
|
|
});
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
};
|
|
|
|
actionsRow.appendChild(deleteDbBtn);
|
|
body.appendChild(actionsRow);
|
|
|
|
section.appendChild(header);
|
|
section.appendChild(body);
|
|
|
|
const title = section.querySelector("#mjr-status-title");
|
|
if (title) {
|
|
title.addEventListener("click", (event) => {
|
|
event.stopPropagation();
|
|
const body = section.querySelector("#mjr-status-body");
|
|
if (!body) return;
|
|
const collapsed = (body as HTMLElement).style.display === "none";
|
|
setBodyCollapsed(section, !collapsed);
|
|
});
|
|
}
|
|
|
|
setBodyCollapsed(section, true);
|
|
// Add hover effect
|
|
section.onmouseenter = () => {
|
|
section.style.transform = "translateY(-1px)";
|
|
};
|
|
section.onmouseleave = () => {
|
|
section.style.transform = "";
|
|
applyStatusHighlight(section, section.dataset?.mjrStatusTone || "neutral", {
|
|
toast: false,
|
|
});
|
|
};
|
|
|
|
const onDbRestoreStatus = async (event: any) => {
|
|
try {
|
|
const detail = event?.detail || {};
|
|
const step = String(detail?.step || "");
|
|
if (!step) return;
|
|
|
|
const isMaintenanceStep =
|
|
step === "started" ||
|
|
step === "stopping_workers" ||
|
|
step === "resetting_db" ||
|
|
step === "delete_db" ||
|
|
step === "recreate_db" ||
|
|
step === "replacing_files" ||
|
|
step === "restarting_scan";
|
|
|
|
// Keep global maintenance lock in sync even if the old panel was closed.
|
|
if (isMaintenanceStep) {
|
|
setMaintenanceActive(true);
|
|
} else if (step === "failed" || step === "done") {
|
|
setMaintenanceActive(false);
|
|
}
|
|
|
|
if (!section?.isConnected || !statusDot?.isConnected || !statusText?.isConnected)
|
|
return;
|
|
|
|
if (isMaintenanceStep) {
|
|
statusDot.style.background = "var(--mjr-status-info, #64B5F6)";
|
|
applyStatusHighlight(section, "info", { toast: false });
|
|
setStatusWithHint(
|
|
statusText,
|
|
t("status.pending", "Pending..."),
|
|
t("status.dbRestoreInProgress", "Database restore in progress"),
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (step === "failed") {
|
|
statusDot.style.background = "var(--mjr-status-error, #f44336)";
|
|
applyStatusHighlight(section, "error", { toast: false });
|
|
setStatusLines(statusText, [
|
|
t("status.error", "Error"),
|
|
String(
|
|
detail?.message ||
|
|
t("toast.dbRestoreFailed", "Failed to restore DB backup"),
|
|
),
|
|
]);
|
|
return;
|
|
}
|
|
|
|
if (step === "done") {
|
|
statusDot.style.background = "var(--mjr-status-success, #4CAF50)";
|
|
applyStatusHighlight(section, "success", { toast: false });
|
|
const op = String(detail?.operation || "");
|
|
const doneHint =
|
|
op === "delete_db"
|
|
? t(
|
|
"toast.dbDeleteSuccess",
|
|
"Database deleted and rebuilt. Files are being reindexed.",
|
|
)
|
|
: op === "reset_index"
|
|
? t(
|
|
"toast.resetStarted",
|
|
"Index reset started. Files will be reindexed in the background.",
|
|
)
|
|
: t("toast.dbRestoreSuccess", "Database backup restored");
|
|
setStatusWithHint(statusText, t("status.ready", "Ready"), doneHint);
|
|
try {
|
|
const target = getScanContext ? getScanContext() : null;
|
|
setTimeout(() => {
|
|
void updateStatus(statusDot, statusText, capabilities, target, null, {
|
|
force: true,
|
|
});
|
|
}, 600);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
};
|
|
try {
|
|
const old = _dbRestoreStatusHandler;
|
|
if (typeof old === "function") {
|
|
window.removeEventListener("mjr-db-restore-status", old);
|
|
}
|
|
_dbRestoreStatusHandler = onDbRestoreStatus;
|
|
(section as any)._mjrDbRestoreStatusHandler = onDbRestoreStatus;
|
|
window.addEventListener("mjr-db-restore-status", onDbRestoreStatus);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
|
|
const onRuntimeStatus = (event: any) => {
|
|
try {
|
|
const detail = event?.detail || {};
|
|
const queueRemaining = Number(detail?.queue_remaining);
|
|
const progressValue = Number(detail?.progress_value);
|
|
const progressMax = Number(detail?.progress_max);
|
|
const cachedNodes = Array.isArray(detail?.cached_nodes)
|
|
? detail.cached_nodes.length
|
|
: 0;
|
|
const activePromptId = String(detail?.active_prompt_id || "").trim();
|
|
const progressLine =
|
|
Number.isFinite(progressValue) && Number.isFinite(progressMax) && progressMax > 0
|
|
? t("status.runtimeProgress", "Runtime progress: {value}/{max}", {
|
|
value: progressValue,
|
|
max: progressMax,
|
|
})
|
|
: "";
|
|
const queueLine = Number.isFinite(queueRemaining)
|
|
? t("status.queueRemaining", "Queue remaining: {count}", {
|
|
count: Math.max(0, queueRemaining),
|
|
})
|
|
: "";
|
|
const cachedLine =
|
|
cachedNodes > 0
|
|
? t("status.executionCached", "Cached nodes reused: {count}", {
|
|
count: cachedNodes,
|
|
})
|
|
: "";
|
|
const promptLine = activePromptId
|
|
? t("status.activePrompt", "Active prompt: {id}", { id: activePromptId })
|
|
: "";
|
|
const liveLines = [progressLine, queueLine, cachedLine, promptLine].filter(Boolean);
|
|
if (!liveLines.length) return;
|
|
const footer = statusText?.querySelector?.("span");
|
|
if (footer) {
|
|
footer.textContent = liveLines.join(" | ");
|
|
} else {
|
|
setStatusWithHint(statusText, t("status.ready", "Ready"), liveLines.join(" | "));
|
|
}
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
};
|
|
try {
|
|
const old = _runtimeStatusHandler;
|
|
if (typeof old === "function") {
|
|
window.removeEventListener(EVENTS.RUNTIME_STATUS, old);
|
|
}
|
|
_runtimeStatusHandler = onRuntimeStatus;
|
|
window.addEventListener(EVENTS.RUNTIME_STATUS, onRuntimeStatus);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
|
|
return section;
|
|
}
|
|
|
|
/**
|
|
* Trigger a scan
|
|
*/
|
|
export async function triggerScan(
|
|
statusDot: any,
|
|
statusText: any,
|
|
capabilitiesSection: any = null,
|
|
scanTarget: any = null,
|
|
): Promise<void> {
|
|
if (_maintenanceActive) {
|
|
comfyToast(
|
|
t("status.maintenanceBusy", "Database maintenance in progress. Please wait."),
|
|
"warning",
|
|
2200,
|
|
);
|
|
return;
|
|
}
|
|
const section =
|
|
statusText?.closest?.("#mjr-status-body")?.parentElement || statusText?.closest?.("div");
|
|
const desiredScope = String(scanTarget?.scope || "output").toLowerCase();
|
|
const desiredCustomRootId =
|
|
scanTarget?.customRootId || scanTarget?.custom_root_id || scanTarget?.root_id || null;
|
|
const isCustomBrowserMode = desiredScope === "custom" && !desiredCustomRootId;
|
|
if (isCustomBrowserMode) {
|
|
statusDot.style.background = "var(--mjr-status-info, #64B5F6)";
|
|
applyStatusHighlight(section, "info");
|
|
setStatusWithHint(
|
|
statusText,
|
|
t("status.customBrowserScanDisabled", "Custom browser mode: scan is disabled"),
|
|
t(
|
|
"status.customBrowserScanDisabledHint",
|
|
"Use Outputs, Inputs, or All to run indexing scans",
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Prevent overlapping scans (manual or polling-triggered).
|
|
try {
|
|
const now = Date.now();
|
|
const inflight = (globalThis as any)?._mjrScanInFlight;
|
|
if (inflight && typeof inflight === "object") {
|
|
const ageMs = now - Number(inflight.at || 0);
|
|
if (ageMs >= 0 && ageMs < 10 * 60_000) {
|
|
setStatusWithHint(
|
|
statusText,
|
|
t("status.scanInProgress", "Scan already running..."),
|
|
t("status.scanInProgressHint", "Please wait for the current scan to finish"),
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
(globalThis as any)._mjrScanInFlight = {
|
|
at: now,
|
|
scope: desiredScope,
|
|
root_id: desiredCustomRootId || null,
|
|
};
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
|
|
const clearScanInFlight = () => {
|
|
try {
|
|
if ((globalThis as any)?._mjrScanInFlight) {
|
|
(globalThis as any)._mjrScanInFlight = null;
|
|
}
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
};
|
|
|
|
// Incremental is always favored.
|
|
// Shift+Click logic is handled by caller (if applicable), but here we default to true.
|
|
const shouldIncremental = true;
|
|
|
|
// Fetch roots so we can show meaningful paths
|
|
let roots = null;
|
|
try {
|
|
const rootsResult = await get(ENDPOINTS.ROOTS);
|
|
if (rootsResult.ok) roots = rootsResult.data;
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
|
|
// Fallback to config for output directory if roots endpoint is unavailable
|
|
if (!roots) {
|
|
const configResult = await get(ENDPOINTS.CONFIG);
|
|
if (!configResult.ok) {
|
|
statusDot.style.background = "var(--mjr-status-error, #f44336)";
|
|
applyStatusHighlight(section, "error");
|
|
statusText.textContent = t("status.errorGetConfig");
|
|
clearScanInFlight();
|
|
return;
|
|
}
|
|
roots = { output_directory: configResult.data.output_directory };
|
|
}
|
|
|
|
const scopeLabel =
|
|
desiredScope === "all"
|
|
? t("scope.all", "Inputs + Outputs")
|
|
: desiredScope === "input"
|
|
? t("scope.input", "Inputs")
|
|
: desiredScope === "custom"
|
|
? t("scope.custom", "Custom")
|
|
: desiredScope === "workflow"
|
|
? t("scope.workflow", "Workflow")
|
|
: t("scope.output", "Outputs");
|
|
|
|
let detail = "";
|
|
if (desiredScope === "input")
|
|
detail = roots?.input_directory ? ` (${roots.input_directory})` : "";
|
|
else if (desiredScope === "custom") {
|
|
const list = Array.isArray(roots?.custom_roots) ? roots.custom_roots : [];
|
|
const picked = desiredCustomRootId ? list.find((r: any) => r?.id === desiredCustomRootId) : null;
|
|
detail = picked?.path ? ` (${picked.path})` : "";
|
|
} else if (desiredScope === "output")
|
|
detail = roots?.output_directory ? ` (${roots.output_directory})` : "";
|
|
|
|
// Show scanning status
|
|
statusDot.style.background = "var(--mjr-status-info, #64B5F6)";
|
|
applyStatusHighlight(section, "info");
|
|
setStatusWithHint(
|
|
statusText,
|
|
t("status.scanningScope", `Scanning ${scopeLabel}${detail}...`, {
|
|
scope: scopeLabel,
|
|
detail,
|
|
}),
|
|
t("status.scanningHint", "This may take a while"),
|
|
);
|
|
|
|
const settings = loadMajoorSettings();
|
|
const fastModeEnabled = !!(settings?.scan?.fastMode ?? true);
|
|
|
|
const payload: Record<string, any> = {
|
|
scope: desiredScope,
|
|
recursive: true,
|
|
incremental: shouldIncremental,
|
|
fast: fastModeEnabled,
|
|
background_metadata: true,
|
|
};
|
|
if (desiredScope === "custom") {
|
|
if (!desiredCustomRootId) {
|
|
statusDot.style.background = "var(--mjr-status-error, #f44336)";
|
|
applyStatusHighlight(section, "error");
|
|
statusText.textContent = t("status.selectCustomFolder");
|
|
clearScanInFlight();
|
|
return;
|
|
}
|
|
payload.custom_root_id = desiredCustomRootId;
|
|
}
|
|
|
|
let scanResult;
|
|
try {
|
|
// Trigger scan
|
|
scanResult = await post(ENDPOINTS.SCAN, payload);
|
|
} finally {
|
|
clearScanInFlight();
|
|
}
|
|
|
|
if (scanResult?.ok) {
|
|
const stats = scanResult.data;
|
|
statusDot.style.background = "var(--mjr-status-success, #4CAF50)"; // Green
|
|
applyStatusHighlight(section, "success");
|
|
setStatusWithHint(
|
|
statusText,
|
|
t("toast.scanComplete"),
|
|
t(
|
|
"status.scanStats",
|
|
`Added: ${stats.added || 0} - Updated: ${stats.updated || 0} - Skipped: ${stats.skipped || 0}`,
|
|
{
|
|
added: stats.added || 0,
|
|
updated: stats.updated || 0,
|
|
skipped: stats.skipped || 0,
|
|
},
|
|
),
|
|
);
|
|
markGridDirty("scan-complete", {
|
|
stats: {
|
|
added: stats.added || 0,
|
|
updated: stats.updated || 0,
|
|
skipped: stats.skipped || 0,
|
|
},
|
|
});
|
|
|
|
// Refresh status after 2 seconds
|
|
setTimeout(() => {
|
|
updateStatus(statusDot, statusText, capabilitiesSection);
|
|
}, 2000);
|
|
} else {
|
|
statusDot.style.background = "var(--mjr-status-error, #f44336)"; // Red
|
|
applyStatusHighlight(section, "error");
|
|
setStatusLines(statusText, [
|
|
t("toast.scanFailed") + `: ${scanResult?.error || "Unknown error"}`,
|
|
]);
|
|
|
|
// Restore status after 3 seconds
|
|
setTimeout(() => {
|
|
updateStatus(statusDot, statusText, capabilitiesSection);
|
|
}, 3000);
|
|
}
|
|
}
|
|
|
|
function createCapabilityBadge(label: any, available: any, hint: any) {
|
|
const badge = document.createElement("span");
|
|
badge.style.cssText = `
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
padding: 2px 6px;
|
|
border-radius: 999px;
|
|
background: rgba(255, 255, 255, 0.05);
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
`;
|
|
badge.textContent = `${available ? "OK" : "ERR"} ${label}`;
|
|
badge.title = available
|
|
? t("status.toolAvailable", `${label} available`, { tool: label })
|
|
: hint || t("status.toolUnavailable", `${label} unavailable`, { tool: label });
|
|
badge.style.opacity = available ? "1" : "0.75";
|
|
return badge;
|
|
}
|
|
|
|
function renderCapabilities(section: any, toolAvailability: Record<string, any> = {}, toolPaths: Record<string, any> = {}) {
|
|
if (!section) return;
|
|
section.replaceChildren();
|
|
if (!toolAvailability || Object.keys(toolAvailability).length === 0) {
|
|
section.textContent = t("status.discoveringTools");
|
|
return;
|
|
}
|
|
|
|
const wrapper = document.createElement("div");
|
|
wrapper.style.display = "flex";
|
|
wrapper.style.flexWrap = "wrap";
|
|
wrapper.style.gap = "10px";
|
|
wrapper.style.alignItems = "flex-start";
|
|
|
|
TOOL_CAPABILITIES.forEach(({ key, labelKey, hintKey }) => {
|
|
const available = Boolean(toolAvailability[key]);
|
|
const path = toolPaths[key];
|
|
const label = t(labelKey, key);
|
|
const hint = t(hintKey, "");
|
|
wrapper.appendChild(createCapabilityNode({ label, hint }, available, path));
|
|
});
|
|
|
|
section.appendChild(wrapper);
|
|
}
|
|
|
|
function renderToolsStatusLine(section: any, toolStatus: any = null, fallbackAvailability: Record<string, any> = {}) {
|
|
if (!section) return;
|
|
const parent = section.parentElement;
|
|
if (!parent) return;
|
|
const container = parent.querySelector("#mjr-tools-status");
|
|
if (!container) return;
|
|
|
|
const hasFallback = fallbackAvailability && Object.keys(fallbackAvailability).length > 0;
|
|
if (!toolStatus && !hasFallback) {
|
|
container.textContent = t("status.toolStatusChecking", "Tool status: checking...");
|
|
return;
|
|
}
|
|
|
|
const segments = TOOL_CAPABILITIES.map(({ key, labelKey }) => {
|
|
const label = t(labelKey, key);
|
|
const availability = toolStatus && key in toolStatus ? toolStatus[key] : undefined;
|
|
const fallback = key in fallbackAvailability ? fallbackAvailability[key] : undefined;
|
|
const available = availability !== undefined ? availability : fallback;
|
|
const statusText =
|
|
available === null || available === undefined
|
|
? t("status.unknown", "unknown")
|
|
: available
|
|
? t("status.available", "available")
|
|
: t("status.missing", "missing");
|
|
const version = toolStatus?.versions?.[key];
|
|
return version ? `${label}: ${statusText} - ${version}` : `${label}: ${statusText}`;
|
|
});
|
|
container.textContent = segments.join(" | ");
|
|
}
|
|
|
|
function createCapabilityNode({ label, hint }: { label: any; hint: any }, available: any, path: any) {
|
|
const container = document.createElement("div");
|
|
container.style.cssText = `
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 3px;
|
|
padding: 6px 8px;
|
|
border-radius: 8px;
|
|
background: rgba(255, 255, 255, 0.03);
|
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
min-width: 170px;
|
|
`;
|
|
|
|
const badge = createCapabilityBadge(label, available, hint);
|
|
badge.style.fontWeight = "500";
|
|
container.appendChild(badge);
|
|
|
|
const pathLine = document.createElement("span");
|
|
pathLine.style.fontSize = "10px";
|
|
pathLine.style.opacity = "0.7";
|
|
pathLine.style.wordBreak = "break-all";
|
|
pathLine.textContent =
|
|
t("status.path", "Path") +
|
|
`: ${path ? path : t("status.pathAuto", "auto / not configured")}`;
|
|
container.appendChild(pathLine);
|
|
|
|
return container;
|
|
}
|
|
|
|
function setBodyCollapsed(section: any, collapsed: any) {
|
|
const body = section.querySelector("#mjr-status-body");
|
|
const indicator = section.querySelector("#mjr-status-title-indicator");
|
|
if (!body || !indicator) return;
|
|
if (collapsed) {
|
|
body.style.display = "none";
|
|
indicator.textContent = ">";
|
|
} else {
|
|
body.style.display = "";
|
|
indicator.textContent = "v";
|
|
}
|
|
}
|
|
function buildDbHealthLine(healthData: any, dbDiagData: any) {
|
|
const dbAvailable = Boolean(healthData?.database?.available);
|
|
const diagnostics = dbDiagData?.diagnostics || {};
|
|
const malformed = Boolean(diagnostics?.malformed);
|
|
const locked = Boolean(diagnostics?.locked);
|
|
|
|
if (!dbAvailable || malformed) {
|
|
return t("status.dbHealthError", "DB health: error");
|
|
}
|
|
if (locked) {
|
|
return t("status.dbHealthLocked", "DB health: locked");
|
|
}
|
|
return t("status.dbHealthOk", "DB health: ok");
|
|
}
|
|
|
|
function buildIndexHealthLine(counters: any, desiredScope: any) {
|
|
const totalAssets = Number(counters?.total_assets || 0);
|
|
const lastIndexEnd = counters?.last_index_end;
|
|
const lastScanEnd = counters?.last_scan_end;
|
|
const scopeLabel =
|
|
desiredScope === "all"
|
|
? t("scope.all", "Inputs + Outputs")
|
|
: desiredScope === "input"
|
|
? t("scope.input", "Inputs")
|
|
: desiredScope === "custom"
|
|
? t("scope.custom", "Custom")
|
|
: desiredScope === "workflow"
|
|
? t("scope.workflow", "Workflow")
|
|
: t("scope.output", "Outputs");
|
|
|
|
if (!Number.isFinite(totalAssets) || totalAssets <= 0) {
|
|
return `${t("status.indexHealthEmpty", "Index health: empty")} (${scopeLabel})`;
|
|
}
|
|
if (!lastIndexEnd && !lastScanEnd) {
|
|
return `${t("status.indexHealthPartial", "Index health: partial")} (${scopeLabel})`;
|
|
}
|
|
return `${t("status.indexHealthOk", "Index health: ok")} (${scopeLabel})`;
|
|
}
|
|
|
|
export async function updateStatus(
|
|
statusDot: any,
|
|
statusText: any,
|
|
capabilitiesSection: any = null,
|
|
scanTarget: any = null,
|
|
meta: any = null,
|
|
options: Record<string, any> = {},
|
|
) {
|
|
const section =
|
|
statusText?.closest?.("#mjr-status-body")?.parentElement || statusText?.closest?.("div");
|
|
const signal = options?.signal || null;
|
|
const force = !!options?.force;
|
|
try {
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
if (!statusDot?.isConnected || !statusText?.isConnected) return null;
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
if (!force && _maintenanceActive) return null;
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
// Optional: caller-provided object to read the last HTTP error details from.
|
|
// (Used by the polling loop to avoid relying on string matching.)
|
|
if (meta && typeof meta === "object") {
|
|
meta.lastCode = null;
|
|
meta.lastStatus = null;
|
|
}
|
|
|
|
const desiredScope = String(scanTarget?.scope || "output").toLowerCase();
|
|
const requestedScope = desiredScope === "workflow" ? "all" : desiredScope;
|
|
const desiredCustomRootId =
|
|
scanTarget?.customRootId || scanTarget?.custom_root_id || scanTarget?.root_id || null;
|
|
const isCustomBrowserMode = desiredScope === "custom" && !desiredCustomRootId;
|
|
const url = isCustomBrowserMode
|
|
? `${ENDPOINTS.HEALTH_COUNTERS}?scope=all`
|
|
: requestedScope === "custom"
|
|
? `${ENDPOINTS.HEALTH_COUNTERS}?scope=custom&custom_root_id=${encodeURIComponent(String(desiredCustomRootId || ""))}`
|
|
: `${ENDPOINTS.HEALTH_COUNTERS}?scope=${encodeURIComponent(requestedScope || "output")}`;
|
|
const lightweight = !!options?.lightweight;
|
|
const scanAuxBackoffActive =
|
|
!force &&
|
|
meta &&
|
|
typeof meta === "object" &&
|
|
Number(meta._scanAuxBackoffUntil || 0) > Date.now();
|
|
const shouldFetchAux =
|
|
!scanAuxBackoffActive &&
|
|
(force || !lightweight || !(meta && typeof meta === "object" && meta._auxCached));
|
|
|
|
let result;
|
|
let healthResult;
|
|
let dbDiagResult;
|
|
let toolStatusData = null;
|
|
|
|
if (shouldFetchAux) {
|
|
const [countersRes, healthRes, dbRes] = await Promise.all([
|
|
get(url, signal ? { signal } : undefined),
|
|
get(ENDPOINTS.HEALTH, signal ? { signal } : undefined),
|
|
get(ENDPOINTS.HEALTH_DB, signal ? { signal } : undefined),
|
|
]);
|
|
result = countersRes;
|
|
healthResult = healthRes;
|
|
dbDiagResult = dbRes;
|
|
try {
|
|
const toolsResult = await getToolsStatus(signal ? { signal } : undefined);
|
|
if (toolsResult?.ok) toolStatusData = toolsResult.data;
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
if (meta && typeof meta === "object") {
|
|
meta._auxCached = true;
|
|
meta._healthCache = healthResult;
|
|
meta._dbDiagCache = dbDiagResult;
|
|
meta._toolsCache = toolStatusData;
|
|
}
|
|
} else {
|
|
result = await get(url, signal ? { signal } : undefined);
|
|
healthResult = meta?._healthCache || null;
|
|
dbDiagResult = meta?._dbDiagCache || null;
|
|
toolStatusData = meta?._toolsCache || null;
|
|
}
|
|
try {
|
|
if (signal?.aborted) return null;
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
if (!statusDot?.isConnected || !statusText?.isConnected) return null;
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
|
|
if (meta && typeof meta === "object") {
|
|
meta.lastCode = result?.code || null;
|
|
meta.lastStatus = typeof result?.status === "number" ? result.status : null;
|
|
}
|
|
|
|
if (result.ok) {
|
|
const counters = result.data;
|
|
if (meta && typeof meta === "object") {
|
|
meta._scanAuxBackoffUntil = counters?.scan_active ? Date.now() + 30_000 : 0;
|
|
}
|
|
const totalAssets = counters.total_assets || 0;
|
|
const withWorkflows = counters.with_workflows - 0;
|
|
const withGenerationData = counters.with_generation_data - 0;
|
|
const workflowCount = Math.max(0, Number(withWorkflows || 0) || 0);
|
|
const lastScanRaw = counters.last_scan_end;
|
|
const lastScanText = lastScanRaw ? new Date(lastScanRaw).toLocaleString() : "N/A";
|
|
const toolAvailability = counters.tool_availability || {};
|
|
const toolPaths = counters.tool_paths || {};
|
|
const dbSizeLine = t("status.dbSize", "Database size: {size}", {
|
|
size: formatBytes(counters.db_size_bytes || 0),
|
|
});
|
|
const vectorDiag =
|
|
healthResult?.ok && healthResult?.data ? healthResult.data.vector || {} : {};
|
|
const vectorEnabled = vectorDiag?.enabled !== false;
|
|
const vectorLoaded = !!vectorDiag?.loaded;
|
|
const vectorDegraded = !!vectorDiag?.degraded;
|
|
const vectorLastError = String(vectorDiag?.last_error || "").trim();
|
|
const vectorLine = !vectorEnabled
|
|
? "AI vector: disabled"
|
|
: vectorDegraded
|
|
? `AI vector: degraded${vectorLastError ? ` (${vectorLastError})` : ""}`
|
|
: vectorLoaded
|
|
? "AI vector: loaded"
|
|
: "AI vector: initializing";
|
|
const dbHealthLine = buildDbHealthLine(
|
|
healthResult?.data || null,
|
|
dbDiagResult?.data || null,
|
|
);
|
|
const indexHealthLine = buildIndexHealthLine(
|
|
counters,
|
|
isCustomBrowserMode ? "all" : desiredScope,
|
|
);
|
|
const dbAvailable = Boolean(healthResult?.ok && healthResult?.data?.database?.available);
|
|
const dbDiagnostics = dbDiagResult?.ok ? dbDiagResult?.data?.diagnostics || {} : {};
|
|
const dbMalformed = Boolean(dbDiagnostics?.malformed);
|
|
const dbLocked = Boolean(dbDiagnostics?.locked);
|
|
const dbMaintenance = Boolean(dbDiagnostics?.maintenance_active);
|
|
setMaintenanceActive(dbMaintenance);
|
|
const enrichmentQueueLength = Math.max(
|
|
0,
|
|
Number(counters?.enrichment_queue_length || 0) || 0,
|
|
);
|
|
const runtimeEnrichment = getRuntimeEnrichmentState();
|
|
const enrichmentActive = runtimeEnrichment.active || enrichmentQueueLength > 0;
|
|
setRuntimeEnrichmentState(enrichmentActive, enrichmentQueueLength);
|
|
const hasIndexedAssets = Number(totalAssets) > 0;
|
|
const hasIndexTimestamp = Boolean(counters?.last_index_end || counters?.last_scan_end);
|
|
const indexHealthy = hasIndexedAssets && hasIndexTimestamp;
|
|
|
|
let healthTone = "success";
|
|
if (dbMaintenance || enrichmentActive) {
|
|
healthTone = "info";
|
|
} else if (!dbAvailable || dbMalformed) {
|
|
healthTone = "error";
|
|
} else if (dbLocked || !indexHealthy) {
|
|
healthTone = "warning";
|
|
} else if (vectorDegraded) {
|
|
healthTone = "warning";
|
|
}
|
|
const isWorkflowScope = desiredScope === "workflow";
|
|
const displayTone =
|
|
isCustomBrowserMode && healthTone !== "error"
|
|
? "browser"
|
|
: isWorkflowScope && healthTone !== "error"
|
|
? "workflow"
|
|
: healthTone;
|
|
let watcherInfo = counters.watcher;
|
|
try {
|
|
if (!watcherInfo || typeof watcherInfo.enabled !== "boolean") {
|
|
const wres = await getWatcherStatus(signal ? { signal } : undefined);
|
|
if (wres?.ok && wres.data) {
|
|
watcherInfo = {
|
|
enabled: !!wres.data.enabled,
|
|
scope: counters?.watcher?.scope || desiredScope,
|
|
custom_root_id: desiredCustomRootId || null,
|
|
};
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
const watcherLine = isWorkflowScope
|
|
? t("status.watcher.disabledScoped", "Watcher: disabled ({scope})", {
|
|
scope: t("scope.workflow", "Workflow"),
|
|
})
|
|
: isCustomBrowserMode
|
|
? t("status.watcher.disabledScoped", "Watcher: disabled ({scope})", {
|
|
scope: t("scope.customBrowser", "Browser"),
|
|
})
|
|
: formatWatcherLine(watcherInfo, desiredScope);
|
|
const enrichmentLine = enrichmentActive
|
|
? t("status.enrichmentQueue", "Metadata enrichment queue: {count}", {
|
|
count: enrichmentQueueLength,
|
|
})
|
|
: t("status.enrichmentIdle", "Metadata enrichment: idle");
|
|
|
|
renderCapabilities(capabilitiesSection, toolAvailability, toolPaths);
|
|
renderToolsStatusLine(capabilitiesSection, toolStatusData, toolAvailability);
|
|
|
|
const scopeLabel =
|
|
desiredScope === "all"
|
|
? t("scope.allFull", "All (Inputs + Outputs)")
|
|
: desiredScope === "input"
|
|
? t("scope.input", "Inputs")
|
|
: desiredScope === "custom"
|
|
? t("scope.customBrowser", "Browser")
|
|
: desiredScope === "workflow"
|
|
? t("scope.workflow", "Workflow")
|
|
: t("scope.output", "Outputs");
|
|
|
|
if (healthTone === "error") {
|
|
statusDot.style.background = "var(--mjr-status-error, #f44336)";
|
|
applyStatusHighlight(section, "error");
|
|
setStatusWithHint(
|
|
statusText,
|
|
t("status.dbCorrupted", "Database appears corrupted or unavailable."),
|
|
[dbHealthLine, indexHealthLine, dbSizeLine, watcherLine]
|
|
.filter(Boolean)
|
|
.join(" | "),
|
|
);
|
|
return counters;
|
|
}
|
|
|
|
if (isCustomBrowserMode) {
|
|
statusDot.style.background = "var(--mjr-status-browser, #26A69A)";
|
|
applyStatusHighlight(section, "browser");
|
|
setStatusWithHint(
|
|
statusText,
|
|
t("status.ready", "Ready"),
|
|
[
|
|
t(
|
|
"status.browserMetricsHidden",
|
|
"Browser mode: global DB/index metrics hidden",
|
|
),
|
|
watcherLine,
|
|
]
|
|
.filter(Boolean)
|
|
.join(" | "),
|
|
);
|
|
return counters;
|
|
}
|
|
|
|
if (isWorkflowScope) {
|
|
statusDot.style.background = "#d481c3";
|
|
applyStatusHighlight(section, displayTone);
|
|
setStatusLines(
|
|
statusText,
|
|
[
|
|
t(
|
|
"status.workflowsIndexed",
|
|
`${workflowCount.toLocaleString()} workflows indexed (${scopeLabel})`,
|
|
{ count: workflowCount.toLocaleString(), scope: scopeLabel },
|
|
),
|
|
t(
|
|
"status.assetsCountReference",
|
|
`Asset records in DB: ${totalAssets.toLocaleString()}`,
|
|
{ count: totalAssets.toLocaleString() },
|
|
),
|
|
enrichmentLine,
|
|
vectorLine,
|
|
dbHealthLine,
|
|
indexHealthLine,
|
|
dbSizeLine,
|
|
watcherLine,
|
|
],
|
|
t("status.lastScan", `Last scan: ${lastScanText}`, { date: lastScanText }),
|
|
);
|
|
return counters;
|
|
}
|
|
|
|
if (totalAssets === 0) {
|
|
statusDot.style.background =
|
|
displayTone === "browser"
|
|
? "var(--mjr-status-browser, #26A69A)"
|
|
: healthTone === "info"
|
|
? "var(--mjr-status-info, #64B5F6)"
|
|
: healthTone === "warning"
|
|
? "var(--mjr-status-warning, #FFA726)"
|
|
: "var(--mjr-status-success, #4CAF50)";
|
|
applyStatusHighlight(section, displayTone);
|
|
setStatusWithHint(
|
|
statusText,
|
|
t("status.noAssets", `No assets indexed yet (${scopeLabel})`, {
|
|
scope: scopeLabel,
|
|
}),
|
|
[
|
|
t("status.clickToScan", "Click the dot to start a scan"),
|
|
vectorLine,
|
|
dbHealthLine,
|
|
indexHealthLine,
|
|
dbSizeLine,
|
|
watcherLine,
|
|
]
|
|
.filter(Boolean)
|
|
.join(" | "),
|
|
);
|
|
} else {
|
|
statusDot.style.background =
|
|
displayTone === "browser"
|
|
? "var(--mjr-status-browser, #26A69A)"
|
|
: healthTone === "info"
|
|
? "var(--mjr-status-info, #64B5F6)"
|
|
: healthTone === "warning"
|
|
? "var(--mjr-status-warning, #FFA726)"
|
|
: "var(--mjr-status-success, #4CAF50)";
|
|
applyStatusHighlight(section, displayTone);
|
|
setStatusLines(
|
|
statusText,
|
|
[
|
|
t(
|
|
"status.assetsIndexed",
|
|
`${totalAssets.toLocaleString()} assets indexed (${scopeLabel})`,
|
|
{ count: totalAssets.toLocaleString(), scope: scopeLabel },
|
|
),
|
|
_buildKindCountsLine(counters),
|
|
t(
|
|
"status.withWorkflows",
|
|
`With workflows: ${withWorkflows} - Generation data: ${withGenerationData}`,
|
|
{ workflows: withWorkflows, gendata: withGenerationData },
|
|
),
|
|
enrichmentLine,
|
|
vectorLine,
|
|
dbHealthLine,
|
|
indexHealthLine,
|
|
dbSizeLine,
|
|
watcherLine,
|
|
],
|
|
t("status.lastScan", `Last scan: ${lastScanText}`, { date: lastScanText }),
|
|
);
|
|
appendStatusProgressBars(
|
|
statusText,
|
|
buildStatusProgressItems(counters, {
|
|
totalAssets,
|
|
withWorkflows,
|
|
withGenerationData,
|
|
}),
|
|
);
|
|
}
|
|
return counters;
|
|
} else {
|
|
// Error - red
|
|
renderCapabilities(capabilitiesSection, {}, {});
|
|
renderToolsStatusLine(capabilitiesSection, toolStatusData);
|
|
statusDot.style.background = "var(--mjr-status-error, #f44336)";
|
|
applyStatusHighlight(section, "error");
|
|
if (result?.code === "INVALID_RESPONSE" && result?.status === 404) {
|
|
setStatusWithHint(
|
|
statusText,
|
|
t("status.apiNotFound", "Majoor API endpoints not found (404)"),
|
|
t(
|
|
"status.apiNotFoundHint",
|
|
"Backend routes are not loaded. Restart ComfyUI and check the terminal for Majoor import errors.",
|
|
),
|
|
);
|
|
} else {
|
|
const errMsg = String(result?.error || "").toLowerCase();
|
|
const isCorrupt =
|
|
errMsg.includes("malform") ||
|
|
errMsg.includes("corrupt") ||
|
|
errMsg.includes("disk image");
|
|
if (isCorrupt) {
|
|
setStatusWithHint(statusText, t("status.dbCorrupted"), t("status.dbCorruptedHint"));
|
|
if (!(globalThis as any)._mjrCorruptToastShown) {
|
|
(globalThis as any)._mjrCorruptToastShown = true;
|
|
comfyToast(t("toast.resetFailedCorrupt"), "error", 8000);
|
|
}
|
|
} else {
|
|
setStatusLines(statusText, [
|
|
result.error || t("status.errorChecking", "Error checking status"),
|
|
]);
|
|
}
|
|
}
|
|
if (result.code === "SERVICE_UNAVAILABLE") {
|
|
const retryBtn = createRetryButton(
|
|
statusDot,
|
|
statusText,
|
|
capabilitiesSection,
|
|
scanTarget,
|
|
);
|
|
statusText.appendChild(document.createElement("br"));
|
|
statusText.appendChild(retryBtn);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function createRetryButton(statusDot: any, statusText: any, capabilitiesSection = null, scanTarget = null) {
|
|
const button = document.createElement("button");
|
|
button.textContent = t("btn.retryServices");
|
|
button.style.cssText = `
|
|
padding: 4px 10px;
|
|
margin-top: 6px;
|
|
font-size: 11px;
|
|
border-radius: 4px;
|
|
border: 1px solid rgba(255,255,255,0.3);
|
|
background: transparent;
|
|
color: white;
|
|
cursor: pointer;
|
|
`;
|
|
|
|
button.onclick = async () => {
|
|
button.disabled = true;
|
|
button.textContent = t("btn.retrying");
|
|
const retryResult = await post(ENDPOINTS.RETRY_SERVICES, {});
|
|
button.disabled = false;
|
|
button.textContent = t("btn.retryServices");
|
|
if (retryResult.ok) {
|
|
updateStatus(statusDot, statusText, capabilitiesSection, scanTarget);
|
|
} else {
|
|
setStatusLines(statusText, [
|
|
retryResult.error || t("status.retryFailed", "Retry failed"),
|
|
]);
|
|
statusText.appendChild(document.createElement("br"));
|
|
statusText.appendChild(button);
|
|
}
|
|
};
|
|
|
|
return button;
|
|
}
|
|
|
|
function _readCachedStatusCounters() {
|
|
try {
|
|
const raw = localStorage?.getItem?.(STATUS_CACHE_KEY);
|
|
if (!raw) return null;
|
|
const parsed = JSON.parse(raw);
|
|
return parsed && typeof parsed === "object" ? parsed : null;
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function _writeCachedStatusCounters(counters: any) {
|
|
try {
|
|
if (!counters || typeof counters !== "object") return;
|
|
localStorage?.setItem?.(STATUS_CACHE_KEY, JSON.stringify(counters));
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
|
|
function _collectProgressValues(status: any) {
|
|
const values: any[] = [];
|
|
const visit = (node: any, depth = 0) => {
|
|
if (!node || depth > 2) return;
|
|
if (typeof node === "number" && Number.isFinite(node)) {
|
|
values.push(node);
|
|
return;
|
|
}
|
|
if (Array.isArray(node)) {
|
|
for (const item of node) visit(item, depth + 1);
|
|
return;
|
|
}
|
|
if (typeof node !== "object") return;
|
|
for (const [key, value] of Object.entries(node)) {
|
|
const normalizedKey = String(key || "").toLowerCase();
|
|
if (
|
|
normalizedKey.includes("queue") ||
|
|
normalizedKey.includes("progress") ||
|
|
normalizedKey.includes("pending") ||
|
|
normalizedKey.includes("running") ||
|
|
normalizedKey.includes("active") ||
|
|
normalizedKey.includes("scan") ||
|
|
normalizedKey.includes("index")
|
|
) {
|
|
visit(value, depth + 1);
|
|
}
|
|
}
|
|
};
|
|
visit(status, 0);
|
|
return values;
|
|
}
|
|
|
|
function _statusHasActiveWork(counters: any) {
|
|
if (!counters || typeof counters !== "object") return false;
|
|
if (_maintenanceActive) return true;
|
|
try {
|
|
if (getRuntimeEnrichmentState()?.active) return true;
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
const values = _collectProgressValues(counters);
|
|
return values.some((value) => Number(value) > 0);
|
|
}
|
|
|
|
function _buildKindCountsLine(counters: any) {
|
|
const parts = [];
|
|
if (counters.images) parts.push(`Images: ${counters.images}`);
|
|
if (counters.videos) parts.push(`Videos: ${counters.videos}`);
|
|
if (counters.audio) parts.push(`Audio: ${counters.audio}`);
|
|
const model3d = counters.model3d || counters.by_kind?.model3d || 0;
|
|
if (model3d) parts.push(`3D: ${model3d}`);
|
|
return parts.length ? parts.join(" - ") : `Images: 0 - Videos: 0`;
|
|
}
|
|
|
|
function _buildStatusSignature(counters: any) {
|
|
if (!counters || typeof counters !== "object") return "";
|
|
const pick = (value: any) => {
|
|
if (value == null) return null;
|
|
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
|
if (typeof value === "boolean") return value;
|
|
if (typeof value === "string") return value;
|
|
return null;
|
|
};
|
|
return JSON.stringify({
|
|
total_assets: pick(counters.total_assets),
|
|
last_scan_end: pick(counters.last_scan_end),
|
|
last_index_end: pick(counters.last_index_end),
|
|
enrichment_queue_length: pick(counters.enrichment_queue_length),
|
|
images: pick(counters.images),
|
|
videos: pick(counters.videos),
|
|
audio: pick(counters.audio),
|
|
model3d: pick(counters.model3d ?? counters.by_kind?.model3d),
|
|
watcher: {
|
|
enabled: pick(counters?.watcher?.enabled),
|
|
scope: pick(counters?.watcher?.scope),
|
|
},
|
|
});
|
|
}
|
|
|
|
function _hydrateStatusFromCache(statusDot: any, statusText: any, capabilitiesSection: any) {
|
|
const cached = _readCachedStatusCounters();
|
|
if (!cached) return false;
|
|
try {
|
|
renderCapabilities(
|
|
capabilitiesSection,
|
|
cached.tool_availability || {},
|
|
cached.tool_paths || {},
|
|
);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
statusDot.style.background = "var(--mjr-status-info, #64B5F6)";
|
|
setStatusWithHint(
|
|
statusText,
|
|
t("status.cached", "Last known status"),
|
|
t("status.checking", "Checking status..."),
|
|
);
|
|
return true;
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function _advancePollState(currentState: any, counters: any, pollStateMeta: any) {
|
|
const signature = _buildStatusSignature(counters);
|
|
const hasChange = signature && signature !== String(pollStateMeta?.lastSignature || "");
|
|
const hasActiveWork = _statusHasActiveWork(counters);
|
|
|
|
let nextState = currentState || STATUS_POLL_STATE.ACTIVE;
|
|
let cooldownStablePolls = Number(pollStateMeta?.cooldownStablePolls || 0) || 0;
|
|
|
|
if (hasActiveWork) {
|
|
nextState = STATUS_POLL_STATE.ACTIVE;
|
|
cooldownStablePolls = 0;
|
|
} else if (hasChange) {
|
|
nextState = STATUS_POLL_STATE.ACTIVE;
|
|
cooldownStablePolls = 0;
|
|
} else if (nextState === STATUS_POLL_STATE.ACTIVE) {
|
|
nextState = STATUS_POLL_STATE.COOLDOWN;
|
|
cooldownStablePolls = 0;
|
|
} else if (nextState === STATUS_POLL_STATE.COOLDOWN) {
|
|
cooldownStablePolls += 1;
|
|
if (cooldownStablePolls >= 3) {
|
|
nextState = STATUS_POLL_STATE.IDLE;
|
|
cooldownStablePolls = 0;
|
|
}
|
|
} else {
|
|
nextState = hasChange ? STATUS_POLL_STATE.ACTIVE : STATUS_POLL_STATE.IDLE;
|
|
cooldownStablePolls = 0;
|
|
}
|
|
|
|
return {
|
|
nextState,
|
|
signature,
|
|
hasChange,
|
|
hasActiveWork,
|
|
cooldownStablePolls,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Setup status polling
|
|
*/
|
|
export function setupStatusPolling(
|
|
statusDot: any,
|
|
statusText: any,
|
|
section: any,
|
|
capabilitiesSection: any = null,
|
|
onCountersUpdate: ((data: any) => void) | null = null,
|
|
getScanTarget: (() => any) | null = null,
|
|
) {
|
|
const GLOBAL_POLL_KEY = "__MJR_STATUS_POLL_DISPOSE__";
|
|
const pollMeta = { lastCode: null, lastStatus: null, _pollTick: 0, _auxCached: false };
|
|
const pollStateMeta = {
|
|
mode: STATUS_POLL_STATE.ACTIVE,
|
|
cooldownStablePolls: 0,
|
|
lastSignature: "",
|
|
};
|
|
|
|
const pollingAC = typeof AbortController !== "undefined" ? new AbortController() : null;
|
|
const handleUpdate = async () => {
|
|
try {
|
|
if (pollingAC?.signal?.aborted) return null;
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
pollMeta._pollTick = Number(pollMeta._pollTick || 0) + 1;
|
|
const lightweight = pollMeta._pollTick > 1 && pollMeta._pollTick % 4 !== 0;
|
|
const target = typeof getScanTarget === "function" ? getScanTarget() : null;
|
|
const counters = await updateStatus(
|
|
statusDot,
|
|
statusText,
|
|
capabilitiesSection,
|
|
target,
|
|
pollMeta,
|
|
{
|
|
signal: pollingAC?.signal || null,
|
|
lightweight,
|
|
// Keep status updates alive while maintenance is active (or stale).
|
|
force: !!_maintenanceActive || pollMeta._pollTick <= 1,
|
|
},
|
|
);
|
|
if (counters && typeof counters === "object") {
|
|
_writeCachedStatusCounters(counters);
|
|
const transition = _advancePollState(pollStateMeta.mode, counters, pollStateMeta);
|
|
pollStateMeta.mode = transition.nextState;
|
|
pollStateMeta.lastSignature = transition.signature;
|
|
pollStateMeta.cooldownStablePolls = transition.cooldownStablePolls;
|
|
}
|
|
if (counters && typeof onCountersUpdate === "function") {
|
|
try {
|
|
await onCountersUpdate(counters);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
return counters;
|
|
};
|
|
|
|
// Show last known state immediately, then refresh in background.
|
|
_hydrateStatusFromCache(statusDot, statusText, capabilitiesSection);
|
|
handleUpdate();
|
|
|
|
try {
|
|
section._mjrStatusPollDispose?.();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
|
|
// Ensure only one polling loop exists globally (panel rerenders can otherwise duplicate timers).
|
|
try {
|
|
const oldGlobalDispose = (window as any)?.[GLOBAL_POLL_KEY];
|
|
if (typeof oldGlobalDispose === "function") oldGlobalDispose();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
|
|
// Poll using a dynamic interval so settings changes apply without reload.
|
|
let pollTimerId: any = null;
|
|
let consecutiveFailures = 0;
|
|
let lastWasMissingEndpoint = false;
|
|
const getIdleMultiplier = () => {
|
|
try {
|
|
const hidden = typeof document !== "undefined" && !!document.hidden;
|
|
const unfocused =
|
|
typeof document !== "undefined" &&
|
|
typeof document.hasFocus === "function" &&
|
|
!document.hasFocus();
|
|
if (hidden || unfocused) return 4;
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
return 1;
|
|
};
|
|
|
|
const scheduleNext = () => {
|
|
const baseMs =
|
|
STATUS_POLL_INTERVALS_MS[pollStateMeta.mode as keyof typeof STATUS_POLL_INTERVALS_MS] ||
|
|
Math.max(250, Number(APP_CONFIG.STATUS_POLL_INTERVAL) || 2000);
|
|
const rawWaitMs = lastWasMissingEndpoint
|
|
? Math.max(30_000, baseMs)
|
|
: consecutiveFailures > 0
|
|
? Math.min(60_000, Math.round(baseMs * Math.min(8, 1 + consecutiveFailures)))
|
|
: baseMs;
|
|
const waitMs = Math.min(120_000, Math.round(rawWaitMs * getIdleMultiplier()));
|
|
|
|
pollTimerId = setTimeout(async () => {
|
|
try {
|
|
if (pollingAC?.signal?.aborted) return;
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
const counters = await handleUpdate();
|
|
|
|
// If the backend routes aren't loaded, ComfyUI returns an HTML 404 page (non-JSON).
|
|
// Back off to avoid spamming the console/network.
|
|
lastWasMissingEndpoint =
|
|
pollMeta.lastCode === "INVALID_RESPONSE" && pollMeta.lastStatus === 404;
|
|
if (counters) {
|
|
consecutiveFailures = 0;
|
|
} else if (lastWasMissingEndpoint) {
|
|
consecutiveFailures = Math.min(999, consecutiveFailures + 1);
|
|
} else {
|
|
consecutiveFailures = Math.min(999, consecutiveFailures + 1);
|
|
pollStateMeta.mode = STATUS_POLL_STATE.ACTIVE;
|
|
pollStateMeta.cooldownStablePolls = 0;
|
|
}
|
|
|
|
// After a few consecutive 404s, stop polling entirely until reload.
|
|
if (lastWasMissingEndpoint && consecutiveFailures >= 3) {
|
|
try {
|
|
section._mjrStatusPollDispose?.();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
return;
|
|
}
|
|
scheduleNext();
|
|
}, waitMs);
|
|
};
|
|
section._mjrStatusPollDispose = () => {
|
|
try {
|
|
pollingAC?.abort?.();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
if (pollTimerId) clearTimeout(pollTimerId);
|
|
pollTimerId = null;
|
|
};
|
|
try {
|
|
window[GLOBAL_POLL_KEY as any] = section._mjrStatusPollDispose;
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
scheduleNext();
|
|
|
|
const statusDotEl = section.querySelector("#mjr-status-dot");
|
|
if (statusDotEl) {
|
|
statusDotEl.onclick = (event: any) => {
|
|
event.stopPropagation();
|
|
const target = typeof getScanTarget === "function" ? getScanTarget() : null;
|
|
triggerScan(statusDot, statusText, capabilitiesSection, target);
|
|
};
|
|
statusDotEl.title = t("status.clickToScan", "Click to scan");
|
|
}
|
|
}
|