- 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.
429 lines
14 KiB
JavaScript
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("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
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();
|
|
})();
|