(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 = '
No logs yet.
'; return; } output.innerHTML = filtered.map((entry) => { const level = String(entry.log_level || "INFO").toUpperCase(); const step = entry.step_name ? ` (${entry.step_name})` : ""; const message = String(entry.message || "").replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); return `
${formatTime(entry.created_at)} ${level}${step} ${message}
`; }).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 = ''; 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(); })();