249 lines
14 KiB
JavaScript
249 lines
14 KiB
JavaScript
// Frontend 1.53.10 only. No Vue component closures or installed bundles are
|
|
// modified. The private Pinia registry and DOM assumptions are kept here.
|
|
export function inspectNativeQueue(app, version) {
|
|
const stores = app.extensionManager?._p?._s;
|
|
const execution = stores?.get("execution");
|
|
const previews = stores?.get("jobPreview");
|
|
const queue = stores?.get("queue");
|
|
const settings = app.extensionManager?.queueSettings;
|
|
const problems = [];
|
|
if (version !== "1.53.10") problems.push(`Frontend ${version} is unsupported; expected 1.53.10.`);
|
|
if (!Array.isArray(app.queueItems) || !Number.isInteger(app.nextQueueRequestId)) problems.push("Native request association is unavailable.");
|
|
if (!execution?.nodeProgressStatesByJob || typeof previews?.setPreviewUrl !== "function" || typeof queue?.update !== "function") {
|
|
problems.push("Native per-job state stores are unavailable.");
|
|
}
|
|
if (!settings || !document.querySelector('[data-testid="queue-mode-menu-trigger"]')) problems.push("Native Run controls are unavailable.");
|
|
return {problems, execution, previews, queue, settings};
|
|
}
|
|
|
|
export function captureDeadlineDestination(selected, mode, settings, readiness) {
|
|
if (!selected || mode !== "disabled") return null;
|
|
if (!readiness.ready) throw new Error(readiness.message || "Deadline is not ready. Open the Deadline sidebar.");
|
|
return {settings: structuredClone(settings)};
|
|
}
|
|
|
|
export function installNativeControls({app, state, progress, getDestination, getReadiness, openSetup, selectDestination, retry, stop}) {
|
|
let frame;
|
|
let fallback;
|
|
let disposed = false;
|
|
let menuJobId;
|
|
const positions = new Map();
|
|
const cancelledLabels = new Map();
|
|
const stopping = new Set();
|
|
const queueButton = () => document.querySelector('[data-testid="queue-button"]');
|
|
const trigger = () => document.querySelector('[data-testid="queue-mode-menu-trigger"]');
|
|
const runMenu = () => {
|
|
const id = trigger()?.id;
|
|
return id ? document.querySelector(`[role="menu"][aria-labelledby="${CSS.escape(id)}"]`) : null;
|
|
};
|
|
const closeRunMenu = () => { trigger()?.click(); trigger()?.focus(); };
|
|
const schedule = () => {
|
|
if (disposed || frame || fallback) return;
|
|
frame = requestAnimationFrame(render);
|
|
// Webviews can suspend animation frames while their page remains
|
|
// active. Menu actions still need to mount in that state.
|
|
fallback = setTimeout(render, 100);
|
|
};
|
|
|
|
function render() {
|
|
cancelAnimationFrame(frame);
|
|
clearTimeout(fallback);
|
|
frame = undefined;
|
|
fallback = undefined;
|
|
if (disposed) return;
|
|
const farm = getDestination() !== null;
|
|
const readiness = getReadiness();
|
|
const button = queueButton();
|
|
if (button) {
|
|
const text = [...button.childNodes].find(node => node.nodeType === Node.TEXT_NODE && node.textContent.trim());
|
|
if (farm && text && text.textContent.trim() !== "Run (Deadline)") text.textContent = " Run (Deadline)";
|
|
if (farm && button.getAttribute("aria-label") !== "Run (Deadline)") button.setAttribute("aria-label", "Run (Deadline)");
|
|
if (!farm && text?.textContent.includes("Deadline")) text.textContent = " Run";
|
|
if (!farm && button.getAttribute("aria-label") === "Run (Deadline)") button.removeAttribute("aria-label");
|
|
}
|
|
const menu = runMenu();
|
|
if (menu && !menu.querySelector("[data-deadline-mode]")) {
|
|
const item = document.createElement("button");
|
|
item.type = "button";
|
|
item.role = "menuitem";
|
|
item.tabIndex = -1;
|
|
item.dataset.deadlineMode = "farm";
|
|
item.className = menu.querySelector("button")?.className || "";
|
|
item.textContent = "Run (Deadline)";
|
|
item.title = "Whole workflows; finite native batch count. Deadline priority governs ordering, including Shift+Run.";
|
|
item.addEventListener("click", event => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
if (!getReadiness().ready) return;
|
|
state.settings.mode = "disabled";
|
|
selectDestination(true);
|
|
closeRunMenu();
|
|
schedule();
|
|
});
|
|
menu.append(item);
|
|
}
|
|
if (menu) {
|
|
const item = menu.querySelector("[data-deadline-mode]");
|
|
item.disabled = !readiness.ready;
|
|
item.title = readiness.ready ? "Whole workflows; finite native batch count. Deadline priority governs ordering, including Shift+Run." : readiness.message;
|
|
item.setAttribute("aria-disabled", String(!readiness.ready));
|
|
let setup = menu.querySelector("[data-deadline-setup]");
|
|
if (!readiness.ready && openSetup && !setup) {
|
|
setup = document.createElement("button");
|
|
setup.type = "button"; setup.role = "menuitem"; setup.tabIndex = -1;
|
|
setup.dataset.deadlineSetup = ""; setup.className = item.className;
|
|
setup.textContent = "Open Deadline";
|
|
setup.addEventListener("click", event => {
|
|
event.preventDefault(); event.stopPropagation(); closeRunMenu(); openSetup();
|
|
});
|
|
menu.append(setup);
|
|
}
|
|
if (readiness.ready) setup?.remove();
|
|
}
|
|
|
|
for (const row of document.querySelectorAll("[data-job-id]")) {
|
|
const event = progress.runs.get(row.dataset.jobId);
|
|
const active = event && ["pending", "in_progress"].includes(event.status);
|
|
let stopButton = row.querySelector("[data-deadline-stop]");
|
|
if (active && stop && !stopButton) {
|
|
stopButton = document.createElement("button");
|
|
stopButton.type = "button";
|
|
stopButton.dataset.deadlineStop = row.dataset.jobId;
|
|
stopButton.style.cssText = "position:relative;z-index:1;flex-shrink:0;font-size:11px;line-height:22px;padding:0 7px;background:var(--bg-color,#222);border:1px solid var(--border-color,#555);border-radius:4px;cursor:pointer";
|
|
stopButton.addEventListener("click", async click => {
|
|
click.preventDefault(); click.stopPropagation();
|
|
const id = stopButton.dataset.deadlineStop;
|
|
if (stopping.has(id)) return;
|
|
stopping.add(id); schedule();
|
|
try { await stop(id); }
|
|
catch (error) { app.extensionManager.toast.addAlert(`Could not stop Deadline run: ${error.message}`); }
|
|
finally { stopping.delete(id); schedule(); }
|
|
});
|
|
(row.firstElementChild || row).append(stopButton);
|
|
}
|
|
if (!active) stopButton?.remove();
|
|
else if (stopButton) {
|
|
stopButton.dataset.deadlineStop = row.dataset.jobId;
|
|
const requested = event.cancel_requested || stopping.has(row.dataset.jobId);
|
|
stopButton.disabled = !!requested;
|
|
const label = requested ? "Stopping…" : "Stop";
|
|
if (stopButton.textContent !== label) stopButton.textContent = label;
|
|
stopButton.setAttribute("aria-label", requested ? "Stopping Deadline run" : "Stop Deadline run");
|
|
stopButton.title = requested ? "Waiting for the Deadline task to stop" : "Stop this run on Deadline";
|
|
}
|
|
// This frontend groups cancelled runs in its Failed tab. Keep its
|
|
// API status intact and distinguish farm Stop in the native row.
|
|
for (const label of row.querySelectorAll('span.truncate[title]')) {
|
|
if (event?.status === "cancelled" && label.getAttribute("title") === "Failed") {
|
|
cancelledLabels.set(label, {text: label.textContent, title: label.title, run: row.dataset.jobId});
|
|
label.textContent = "Cancelled";
|
|
label.title = "Cancelled";
|
|
} else if (cancelledLabels.has(label) && (event?.status !== "cancelled" || cancelledLabels.get(label).run !== row.dataset.jobId)) {
|
|
const prior = cancelledLabels.get(label);
|
|
if (label.title === "Cancelled") { label.textContent = prior.text; label.title = prior.title; }
|
|
cancelledLabels.delete(label);
|
|
}
|
|
}
|
|
let badge = row.querySelector("[data-deadline-progress]");
|
|
if (!event || event.status !== "in_progress") { badge?.remove(); continue; }
|
|
if (!badge) {
|
|
badge = document.createElement("span");
|
|
badge.dataset.deadlineProgress = row.dataset.jobId;
|
|
badge.style.cssText = "position:absolute;right:44px;bottom:0;font-size:10px;line-height:12px;background:var(--bg-color,#222);padding:0 3px;pointer-events:none";
|
|
if (!positions.has(row)) positions.set(row, row.style.position);
|
|
row.style.position = "relative";
|
|
row.append(badge);
|
|
}
|
|
const nodes = Object.values(event.nodes || {});
|
|
const max = nodes.reduce((sum, node) => sum + node.max, 0);
|
|
const value = nodes.reduce((sum, node) => sum + node.value, 0);
|
|
const label = event.cancel_requested ? "Deadline stopping" : max > 0 ? `Deadline ${Math.round(100 * value / max)}%` : "Deadline initializing";
|
|
if (badge.textContent !== label) badge.textContent = label;
|
|
}
|
|
for (const row of positions.keys()) if (!row.isConnected) positions.delete(row);
|
|
for (const label of cancelledLabels.keys()) if (!label.isConnected) cancelledLabels.delete(label);
|
|
|
|
// Copy-ID is a stable, inspected native context-menu action on this
|
|
// supported English frontend. Unsupported menu layouts get no injection.
|
|
for (const anchor of document.querySelectorAll('button[aria-label="Copy job ID"]')) {
|
|
const menu = anchor.parentElement;
|
|
const event = progress.runs.get(menuJobId);
|
|
const terminal = event && ["completed", "failed", "cancelled"].includes(event.status);
|
|
let action = menu.querySelector("[data-deadline-retry]");
|
|
if (!terminal) { action?.remove(); continue; }
|
|
if (action?.dataset.deadlineRetry === menuJobId) continue;
|
|
action?.remove();
|
|
action = document.createElement("button");
|
|
action.type = "button";
|
|
action.dataset.deadlineRetry = menuJobId;
|
|
action.setAttribute("aria-label", "Retry on Deadline");
|
|
action.className = anchor.className;
|
|
action.textContent = "Retry on Deadline";
|
|
const id = menuJobId;
|
|
action.addEventListener("click", async event => {
|
|
event.stopPropagation();
|
|
action.disabled = true;
|
|
try { await retry(id); } finally { action.disabled = false; }
|
|
});
|
|
menu.append(action);
|
|
}
|
|
}
|
|
|
|
function captureClick(event) {
|
|
const row = event.target.closest?.("[data-job-id]");
|
|
if (row) menuJobId = row.dataset.jobId;
|
|
const menu = runMenu();
|
|
if (menu?.contains(event.target) && !event.target.closest("[data-deadline-mode],[data-deadline-setup]")) {
|
|
selectDestination(false);
|
|
const button = queueButton();
|
|
const text = [...(button?.childNodes || [])].find(node => node.nodeType === Node.TEXT_NODE && node.textContent.trim());
|
|
if (text?.textContent.includes("Deadline")) text.textContent = " Run";
|
|
button?.removeAttribute("aria-label");
|
|
schedule();
|
|
}
|
|
}
|
|
function keydown(event) {
|
|
const menu = runMenu();
|
|
if (!menu?.contains(event.target)) return;
|
|
const items = [...menu.querySelectorAll('[role="menuitem"]')].filter(item => !item.disabled);
|
|
const index = items.indexOf(event.target);
|
|
const extensionItem = item => item?.matches("[data-deadline-mode],[data-deadline-setup]");
|
|
if (index >= 0 && ["ArrowDown", "ArrowUp"].includes(event.key)) {
|
|
const next = items[(index + (event.key === "ArrowDown" ? 1 : -1) + items.length) % items.length];
|
|
if (extensionItem(event.target) || extensionItem(next)) {
|
|
event.preventDefault(); event.stopImmediatePropagation(); next.focus();
|
|
}
|
|
} else if (extensionItem(event.target) && ["Enter", " "].includes(event.key)) {
|
|
event.preventDefault(); event.stopImmediatePropagation(); event.target.click();
|
|
}
|
|
}
|
|
|
|
const observer = new MutationObserver(schedule);
|
|
observer.observe(document.body, {childList: true, subtree: true, characterData: true});
|
|
document.addEventListener("click", captureClick, true);
|
|
document.addEventListener("contextmenu", captureClick, true);
|
|
document.addEventListener("keydown", keydown, true);
|
|
schedule();
|
|
return {
|
|
render: schedule,
|
|
dispose() {
|
|
disposed = true;
|
|
observer.disconnect();
|
|
cancelAnimationFrame(frame);
|
|
clearTimeout(fallback);
|
|
document.removeEventListener("click", captureClick, true);
|
|
document.removeEventListener("contextmenu", captureClick, true);
|
|
document.removeEventListener("keydown", keydown, true);
|
|
for (const element of document.querySelectorAll("[data-deadline-mode],[data-deadline-setup],[data-deadline-progress],[data-deadline-retry],[data-deadline-stop]")) element.remove();
|
|
for (const [row, position] of positions) row.style.position = position;
|
|
for (const [label, prior] of cancelledLabels) {
|
|
if (label.title === "Cancelled") { label.textContent = prior.text; label.title = prior.title; }
|
|
}
|
|
const button = queueButton();
|
|
const text = [...(button?.childNodes || [])].find(node => node.nodeType === Node.TEXT_NODE && node.textContent.includes("Deadline"));
|
|
if (text) text.textContent = " Run";
|
|
button?.removeAttribute("aria-label");
|
|
}
|
|
};
|
|
}
|