Files
jiggablend/web/static/job_show.js
Justin Harms 2deb47e5ad Refactor web build process and update documentation
- Removed Node.js build artifacts from .gitignore and adjusted Makefile to reflect changes in web UI build process, now using server-rendered Go templates instead of React.
- Updated README to clarify the new web UI architecture and output formats, emphasizing the removal of the Node.js build step.
- Added a command to set the number of frames per render task in manager configuration, enhancing user control over rendering settings.
- Improved Gitea workflow by removing unnecessary npm install step, streamlining the CI process.
2026-03-12 19:44:40 -05:00

429 lines
14 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;
}
output.innerHTML = filtered.map((entry) => {
const level = String(entry.log_level || "INFO").toUpperCase();
const step = entry.step_name ? ` <span class="log-step">(${entry.step_name})</span>` : "";
const message = String(entry.message || "").replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
return `<div class="log-line">
<span class="log-time">${formatTime(entry.created_at)}</span>
<span class="log-level ${levelClass(level)}">${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();
})();