1a69fcfd04
- Updated the installation script to clarify that production wrappers do not include fixed test secrets, improving user guidance for local testing. - Modified the jiggablend-manager and jiggablend-runner scripts to remove fixed test configurations, emphasizing the use of local test credentials via `make init-test`. - Enhanced error handling in the runner script to ensure that an API key is provided, improving security and user feedback. - Added new flags and options for the runner, including sandboxing capabilities and GPU ray tracing control, enhancing flexibility for users. - Improved README documentation to reflect changes in usage and configuration, ensuring users have clear instructions for setup and execution.
436 lines
15 KiB
JavaScript
436 lines
15 KiB
JavaScript
(function () {
|
|
const jobID = window.location.pathname.split("/").pop();
|
|
const progressFill = document.querySelector(".progress-fill[data-progress]");
|
|
const progressText = document.getElementById("job-progress-text");
|
|
const statusBadge = document.getElementById("job-status-badge");
|
|
const tasksRefreshBtn = document.getElementById("tasks-refresh");
|
|
const tasksFragment = document.getElementById("tasks-fragment");
|
|
const filesRefreshBtn = document.getElementById("files-refresh");
|
|
const filesFragment = document.getElementById("files-fragment");
|
|
const cancelJobBtn = document.getElementById("cancel-job-btn");
|
|
const deleteJobBtn = document.getElementById("delete-job-btn");
|
|
const previewModal = document.getElementById("exr-preview-modal");
|
|
const previewImage = document.getElementById("exr-preview-image");
|
|
const previewLoading = document.getElementById("exr-preview-loading");
|
|
const previewError = document.getElementById("exr-preview-error");
|
|
const previewName = document.getElementById("exr-preview-name");
|
|
let lastJobSnapshot = null;
|
|
let lastSmartRefreshAt = 0;
|
|
if (progressFill) {
|
|
const value = Number(progressFill.getAttribute("data-progress") || "0");
|
|
const bounded = Math.max(0, Math.min(100, value));
|
|
progressFill.style.width = `${bounded}%`;
|
|
}
|
|
|
|
function statusClass(status) {
|
|
const normalized = String(status || "").toLowerCase();
|
|
if (normalized === "completed") return "status-completed";
|
|
if (normalized === "running") return "status-running";
|
|
if (normalized === "failed") return "status-failed";
|
|
if (normalized === "cancelled") return "status-cancelled";
|
|
return "status-pending";
|
|
}
|
|
|
|
function applyJobState(job) {
|
|
if (!job) return;
|
|
const normalizedStatus = String(job.status || "pending").toLowerCase();
|
|
const canCancel = normalizedStatus === "pending" || normalizedStatus === "running";
|
|
const canDelete = normalizedStatus === "completed" || normalizedStatus === "failed" || normalizedStatus === "cancelled";
|
|
const progressValue = Math.max(0, Math.min(100, Number(job.progress || 0)));
|
|
if (progressFill) {
|
|
progressFill.style.width = `${progressValue}%`;
|
|
progressFill.setAttribute("data-progress", String(Math.round(progressValue)));
|
|
}
|
|
if (progressText) {
|
|
progressText.textContent = `${Math.round(progressValue)}%`;
|
|
}
|
|
if (statusBadge) {
|
|
statusBadge.textContent = normalizedStatus;
|
|
statusBadge.classList.remove("status-pending", "status-running", "status-completed", "status-failed", "status-cancelled");
|
|
statusBadge.classList.add(statusClass(job.status));
|
|
}
|
|
if (cancelJobBtn) {
|
|
cancelJobBtn.classList.toggle("hidden", !canCancel);
|
|
}
|
|
if (deleteJobBtn) {
|
|
deleteJobBtn.classList.toggle("hidden", !canDelete);
|
|
}
|
|
}
|
|
|
|
function refreshTasksAndFiles() {
|
|
if (!window.htmx) return;
|
|
if (tasksFragment) {
|
|
htmx.ajax("GET", `/ui/fragments/jobs/${jobID}/tasks`, "#tasks-fragment");
|
|
}
|
|
if (filesFragment) {
|
|
htmx.ajax("GET", `/ui/fragments/jobs/${jobID}/files`, "#files-fragment");
|
|
}
|
|
lastSmartRefreshAt = Date.now();
|
|
}
|
|
|
|
async function pollJobState() {
|
|
try {
|
|
const res = await fetch(`/api/jobs/${jobID}`, { credentials: "include" });
|
|
if (!res.ok) return;
|
|
const job = await res.json();
|
|
applyJobState(job);
|
|
|
|
const snapshot = {
|
|
status: String(job.status || ""),
|
|
progress: Math.round(Number(job.progress || 0)),
|
|
startedAt: job.started_at || "",
|
|
completedAt: job.completed_at || "",
|
|
};
|
|
const changed =
|
|
!lastJobSnapshot ||
|
|
snapshot.status !== lastJobSnapshot.status ||
|
|
snapshot.progress !== lastJobSnapshot.progress ||
|
|
snapshot.startedAt !== lastJobSnapshot.startedAt ||
|
|
snapshot.completedAt !== lastJobSnapshot.completedAt;
|
|
lastJobSnapshot = snapshot;
|
|
|
|
// Smart refresh fragments only when job state changes.
|
|
if (changed) {
|
|
refreshTasksAndFiles();
|
|
return;
|
|
}
|
|
|
|
// Fallback while running: refresh infrequently even without visible progress deltas.
|
|
if (snapshot.status === "running" && Date.now() - lastSmartRefreshAt > 12000) {
|
|
refreshTasksAndFiles();
|
|
}
|
|
} catch (_) {
|
|
// Keep UI usable even if polling briefly fails.
|
|
}
|
|
}
|
|
|
|
if (tasksRefreshBtn && tasksFragment && window.htmx) {
|
|
tasksRefreshBtn.addEventListener("click", () => {
|
|
htmx.ajax("GET", `/ui/fragments/jobs/${jobID}/tasks`, "#tasks-fragment");
|
|
});
|
|
}
|
|
if (filesRefreshBtn && filesFragment && window.htmx) {
|
|
filesRefreshBtn.addEventListener("click", () => {
|
|
htmx.ajax("GET", `/ui/fragments/jobs/${jobID}/files`, "#files-fragment");
|
|
});
|
|
}
|
|
pollJobState();
|
|
setInterval(pollJobState, 2500);
|
|
|
|
async function apiRequest(url, method) {
|
|
const res = await fetch(url, {
|
|
method,
|
|
credentials: "include",
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
throw new Error(data.error || "Request failed");
|
|
}
|
|
return data;
|
|
}
|
|
|
|
function closePreviewModal() {
|
|
if (!previewModal) return;
|
|
previewModal.classList.add("hidden");
|
|
if (previewImage) {
|
|
previewImage.classList.add("hidden");
|
|
previewImage.removeAttribute("src");
|
|
}
|
|
if (previewLoading) previewLoading.classList.remove("hidden");
|
|
if (previewError) {
|
|
previewError.classList.add("hidden");
|
|
previewError.textContent = "";
|
|
}
|
|
}
|
|
|
|
function openPreviewModal(url, name) {
|
|
if (!previewModal || !previewImage) return;
|
|
previewModal.classList.remove("hidden");
|
|
if (previewName) previewName.textContent = name ? `File: ${name}` : "";
|
|
if (previewLoading) previewLoading.classList.remove("hidden");
|
|
if (previewError) {
|
|
previewError.classList.add("hidden");
|
|
previewError.textContent = "";
|
|
}
|
|
previewImage.classList.add("hidden");
|
|
previewImage.onload = () => {
|
|
if (previewLoading) previewLoading.classList.add("hidden");
|
|
previewImage.classList.remove("hidden");
|
|
};
|
|
previewImage.onerror = () => {
|
|
if (previewLoading) previewLoading.classList.add("hidden");
|
|
if (previewError) {
|
|
previewError.textContent = "Failed to load preview image.";
|
|
previewError.classList.remove("hidden");
|
|
}
|
|
};
|
|
previewImage.src = url;
|
|
}
|
|
|
|
document.body.addEventListener("click", async (e) => {
|
|
const previewBtn = e.target.closest("[data-exr-preview-url]");
|
|
if (previewBtn) {
|
|
const url = previewBtn.getAttribute("data-exr-preview-url");
|
|
const name = previewBtn.getAttribute("data-exr-preview-name");
|
|
if (url) {
|
|
openPreviewModal(url, name || "");
|
|
}
|
|
return;
|
|
}
|
|
|
|
const modalClose = e.target.closest("[data-modal-close]");
|
|
if (modalClose) {
|
|
closePreviewModal();
|
|
return;
|
|
}
|
|
|
|
const cancelBtn = e.target.closest("[data-cancel-job]");
|
|
const deleteBtn = e.target.closest("[data-delete-job]");
|
|
if (!cancelBtn && !deleteBtn) return;
|
|
const id = (cancelBtn || deleteBtn).getAttribute(cancelBtn ? "data-cancel-job" : "data-delete-job");
|
|
try {
|
|
if (cancelBtn) {
|
|
if (!confirm("Cancel this job?")) return;
|
|
await apiRequest(`/api/jobs/${id}`, "DELETE");
|
|
} else {
|
|
if (!confirm("Delete this job permanently?")) return;
|
|
await apiRequest(`/api/jobs/${id}/delete`, "POST");
|
|
window.location.href = "/jobs";
|
|
return;
|
|
}
|
|
window.location.reload();
|
|
} catch (err) {
|
|
alert(err.message);
|
|
}
|
|
});
|
|
|
|
document.addEventListener("keydown", (e) => {
|
|
if (e.key === "Escape") {
|
|
closePreviewModal();
|
|
}
|
|
});
|
|
|
|
const taskSelect = document.getElementById("task-log-task-id");
|
|
const levelFilter = document.getElementById("task-log-level-filter");
|
|
const autoRefreshToggle = document.getElementById("task-log-auto-refresh");
|
|
const followToggle = document.getElementById("task-log-follow");
|
|
const refreshBtn = document.getElementById("task-log-refresh");
|
|
const copyBtn = document.getElementById("task-log-copy");
|
|
const output = document.getElementById("task-log-output");
|
|
const statusEl = document.getElementById("task-log-status");
|
|
const state = {
|
|
timer: null,
|
|
activeTaskID: "",
|
|
lastLogID: 0,
|
|
logs: [],
|
|
seenIDs: new Set(),
|
|
};
|
|
|
|
function setStatus(text) {
|
|
if (statusEl) statusEl.textContent = text;
|
|
}
|
|
|
|
function levelClass(level) {
|
|
const normalized = String(level || "INFO").toUpperCase();
|
|
if (normalized === "ERROR") return "log-error";
|
|
if (normalized === "WARN") return "log-warn";
|
|
if (normalized === "DEBUG") return "log-debug";
|
|
return "log-info";
|
|
}
|
|
|
|
function formatTime(ts) {
|
|
if (!ts) return "--:--:--";
|
|
const d = new Date(ts);
|
|
if (Number.isNaN(d.getTime())) return "--:--:--";
|
|
return d.toLocaleTimeString();
|
|
}
|
|
|
|
function renderLogs() {
|
|
if (!output) return;
|
|
const selectedLevel = (levelFilter?.value || "").toUpperCase();
|
|
const filtered = state.logs.filter((entry) => {
|
|
if (!selectedLevel) return true;
|
|
return String(entry.log_level || "").toUpperCase() === selectedLevel;
|
|
});
|
|
|
|
if (filtered.length === 0) {
|
|
output.innerHTML = '<div class="log-line empty">No logs yet.</div>';
|
|
return;
|
|
}
|
|
|
|
const escapeHtml = (value) => String(value ?? "")
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
output.innerHTML = filtered.map((entry) => {
|
|
const level = String(entry.log_level || "INFO").toUpperCase();
|
|
const stepName = entry.step_name ? escapeHtml(entry.step_name) : "";
|
|
const step = stepName ? ` <span class="log-step">(${stepName})</span>` : "";
|
|
const message = escapeHtml(entry.message || "");
|
|
return `<div class="log-line">
|
|
<span class="log-time">${escapeHtml(formatTime(entry.created_at))}</span>
|
|
<span class="log-level ${levelClass(level)}">${escapeHtml(level)}</span>${step}
|
|
<span class="log-message">${message}</span>
|
|
</div>`;
|
|
}).join("");
|
|
|
|
if (followToggle?.checked) {
|
|
output.scrollTop = output.scrollHeight;
|
|
}
|
|
}
|
|
|
|
function getVisibleLogs() {
|
|
const selectedLevel = (levelFilter?.value || "").toUpperCase();
|
|
return state.logs.filter((entry) => {
|
|
if (!selectedLevel) return true;
|
|
return String(entry.log_level || "").toUpperCase() === selectedLevel;
|
|
});
|
|
}
|
|
|
|
function logsToText(entries) {
|
|
return entries.map((entry) => {
|
|
const level = String(entry.log_level || "INFO").toUpperCase();
|
|
const step = entry.step_name ? ` (${entry.step_name})` : "";
|
|
return `[${formatTime(entry.created_at)}] [${level}]${step} ${entry.message || ""}`;
|
|
}).join("\n");
|
|
}
|
|
|
|
function collectTaskOptions() {
|
|
if (!taskSelect) return;
|
|
const buttons = document.querySelectorAll("[data-view-logs-task-id]");
|
|
const current = taskSelect.value;
|
|
|
|
taskSelect.innerHTML = '<option value="">Choose a task...</option>';
|
|
buttons.forEach((btn) => {
|
|
const id = btn.getAttribute("data-view-logs-task-id");
|
|
if (!id) return;
|
|
const row = btn.closest("tr");
|
|
const status = row?.querySelector(".status")?.textContent?.trim() || "";
|
|
const type = row?.children?.[1]?.textContent?.trim() || "";
|
|
const option = document.createElement("option");
|
|
option.value = id;
|
|
option.textContent = `#${id} ${type ? `(${type})` : ""} ${status ? `- ${status}` : ""}`.trim();
|
|
taskSelect.appendChild(option);
|
|
});
|
|
|
|
if (current && taskSelect.querySelector(`option[value="${current}"]`)) {
|
|
taskSelect.value = current;
|
|
}
|
|
}
|
|
|
|
async function fetchLogs({ reset = false, full = false } = {}) {
|
|
const taskID = taskSelect?.value?.trim();
|
|
if (!taskID) {
|
|
setStatus("Select a task to view logs.");
|
|
return;
|
|
}
|
|
|
|
if (reset || taskID !== state.activeTaskID) {
|
|
state.activeTaskID = taskID;
|
|
state.lastLogID = 0;
|
|
state.logs = [];
|
|
state.seenIDs.clear();
|
|
renderLogs();
|
|
}
|
|
|
|
const params = new URLSearchParams();
|
|
params.set("limit", "0"); // backend: 0 = no limit
|
|
if (!full && state.lastLogID > 0) {
|
|
params.set("since_id", String(state.lastLogID));
|
|
}
|
|
|
|
try {
|
|
const res = await fetch(`/api/jobs/${jobID}/tasks/${taskID}/logs?${params.toString()}`, {
|
|
credentials: "include",
|
|
});
|
|
if (!res.ok) {
|
|
setStatus(`Failed to fetch logs (HTTP ${res.status}).`);
|
|
return;
|
|
}
|
|
|
|
const payload = await res.json();
|
|
const rows = Array.isArray(payload) ? payload : (payload.logs || []);
|
|
if (rows.length > 0) {
|
|
for (const row of rows) {
|
|
const id = Number(row.id || 0);
|
|
if (id > 0 && !state.seenIDs.has(id)) {
|
|
state.seenIDs.add(id);
|
|
state.logs.push(row);
|
|
if (id > state.lastLogID) state.lastLogID = id;
|
|
}
|
|
}
|
|
if (!Array.isArray(payload) && Number(payload.last_id || 0) > state.lastLogID) {
|
|
state.lastLogID = Number(payload.last_id);
|
|
}
|
|
}
|
|
|
|
setStatus(`Task #${taskID}: ${state.logs.length} log line(s).`);
|
|
renderLogs();
|
|
} catch (err) {
|
|
setStatus(`Failed to fetch logs: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
function restartPolling() {
|
|
if (state.timer) {
|
|
clearInterval(state.timer);
|
|
state.timer = null;
|
|
}
|
|
if (!autoRefreshToggle?.checked) return;
|
|
state.timer = setInterval(() => {
|
|
if (taskSelect?.value) {
|
|
fetchLogs();
|
|
}
|
|
}, 2000);
|
|
}
|
|
|
|
if (tasksFragment) {
|
|
tasksFragment.addEventListener("htmx:afterSwap", () => {
|
|
collectTaskOptions();
|
|
});
|
|
}
|
|
collectTaskOptions();
|
|
|
|
document.body.addEventListener("click", (e) => {
|
|
const viewBtn = e.target.closest("[data-view-logs-task-id]");
|
|
if (!viewBtn || !taskSelect) return;
|
|
const taskID = viewBtn.getAttribute("data-view-logs-task-id");
|
|
if (!taskID) return;
|
|
taskSelect.value = taskID;
|
|
fetchLogs({ reset: true, full: true });
|
|
});
|
|
|
|
if (taskSelect) {
|
|
taskSelect.addEventListener("change", () => fetchLogs({ reset: true, full: true }));
|
|
}
|
|
if (levelFilter) {
|
|
levelFilter.addEventListener("change", renderLogs);
|
|
}
|
|
if (refreshBtn) {
|
|
refreshBtn.addEventListener("click", () => fetchLogs({ reset: true, full: true }));
|
|
}
|
|
if (copyBtn) {
|
|
copyBtn.addEventListener("click", async () => {
|
|
const visible = getVisibleLogs();
|
|
if (visible.length === 0) {
|
|
setStatus("No logs to copy.");
|
|
return;
|
|
}
|
|
try {
|
|
await navigator.clipboard.writeText(logsToText(visible));
|
|
setStatus(`Copied ${visible.length} log line(s).`);
|
|
} catch (_) {
|
|
setStatus("Clipboard copy failed.");
|
|
}
|
|
});
|
|
}
|
|
if (autoRefreshToggle) {
|
|
autoRefreshToggle.addEventListener("change", restartPolling);
|
|
}
|
|
|
|
restartPolling();
|
|
})();
|