// VRSub 静态前端公共脚本:所有页面共用的 API 封装、渲染函数与事件绑定。 // 首页只负责发起任务;任务管理页展示全部任务的进度、产物下载与失败重试。 // 演示"视频字幕生成"工作流的 DAG 定义,预填在工作流编排页。 const DEMO_WORKFLOW = { name: "视频字幕生成", version: 1, nodes: [ { id: "extract", node_type: "ffmpeg-extract", params: { sample_rate: 16000, channels: 1 }, inputs: { video_uri: "input.video_uri" }, }, { id: "asr", node_type: "faster-whisper", params: { language: "ja" }, inputs: { audio_uri: "extract.audio_uri" }, }, { id: "translate", node_type: "llm-translate", params: { target_language: "zh-CN" }, inputs: { srt_uri: "asr.srt_uri" }, }, { id: "ass", node_type: "srt-to-dual-eye-ass", params: { resolution: "3840x1920" }, inputs: { cn_srt_uri: "translate.cn_srt_uri" }, }, ], edges: [ { from: "extract", to: "asr" }, { from: "asr", to: "translate" }, { from: "translate", to: "ass" }, ], entry_inputs: { video_uri: "file" }, final_outputs: { cn_srt: "translate.cn_srt_uri", ass: "ass.ass_uri", }, }; // 统一封装 fetch:自动携带 JSON 头、解析响应并在失败时抛出可读错误。 async function api(path, options = {}) { const response = await fetch(path, { headers: { "Content-Type": "application/json", ...(options.headers || {}) }, ...options, }); const data = await response.json().catch(() => null); if (!response.ok) { // FastAPI 的校验错误 detail 可能是数组,统一序列化为字符串展示。 const detail = data && data.detail ? JSON.stringify(data.detail) : response.statusText; throw new Error(`${response.status} ${detail}`); } return data; } // 转义用户可控文本,防止 XSS 注入到表格或状态 HTML 中。 function escapeHtml(value) { return String(value) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } // 根据状态生成带语义颜色的徽章 HTML。 function badge(status) { // 统一转小写比较,兼容后端返回的不同大小写。 const value = String(status).toLowerCase(); const className = value === "completed" || value === "ok" ? "ok" : value === "error" || value === "failed" ? "error" : "warn"; return `${escapeHtml(status)}`; } // 把 ISO 时间格式化为本地时间;非法值原样返回。 function formatTime(value) { if (!value) return ""; const date = new Date(value); return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); } // 计算距给定时间的流逝时长,用于任务页展示已运行时间。 function formatElapsed(value) { if (!value) return ""; const seconds = Math.floor((Date.now() - new Date(value).getTime()) / 1000); if (Number.isNaN(seconds) || seconds < 0) return ""; if (seconds < 60) return `${seconds} 秒`; return `${Math.floor(seconds / 60)} 分钟`; } // 健康检查失败时在页面顶部展示后端不可用提示。 async function loadHealth() { try { await api("/health"); } catch (error) { const banner = document.createElement("div"); banner.className = "result"; banner.textContent = `后端不可用:${error.message}`; document.body.prepend(banner); } } // 加载工作流列表并渲染发布状态与操作按钮。 async function loadWorkflows() { const workflows = await api("/api/admin/workflows"); const tbody = document.getElementById("workflowList"); if (!tbody) return; tbody.innerHTML = workflows.length ? workflows .map( (workflow) => ` ${escapeHtml(workflow.id)} ${escapeHtml(workflow.name)} ${escapeHtml(workflow.latest_version)} ${workflow.published ? badge("ok") : badge("draft")} ${workflow.published ? "" : ``} `, ) .join("") : '暂无工作流'; } // 加载已发布的工作流(应用),填充首页的下拉选择框。 // 已发布工作流缓存(id → {name, definition}),供按需切换 OCR 面板。 let workflowApps = []; // 需要 crop 的节点 ID(如 frame-extract)与用户框选的 crop 值。 let cropNodeId = null; let selectedCrop = null; // 框选模式:开启后才让画布接收鼠标事件,平时不拦截视频控件。 let drawMode = false; // 当前选择的视频文件:change 时保存,提交时使用(避免被重置清空 input 丢失)。 let selectedVideoFile = null; async function loadWorkflowOptions() { const select = document.getElementById("workflowSelect"); if (!select) return; workflowApps = await api("/api/apps"); select.innerHTML = workflowApps.length ? workflowApps .map( (app) => ``, ) .join("") : ''; // 切换工作流时按需展示框选面板。 select.addEventListener("change", onWorkflowChange); await onWorkflowChange(); } // 切换工作流:若 DAG 中存在带 crop 参数的节点(字幕 OCR 流程), // 切换到框选面板;否则使用标准上传。 async function onWorkflowChange() { const select = document.getElementById("workflowSelect"); const ocrCard = document.getElementById("ocrCard"); const standardCard = document.getElementById("standardCard"); if (!select || !ocrCard || !standardCard) return; cropNodeId = null; selectedCrop = null; const app = workflowApps.find((item) => item.id === select.value); // 数据驱动判断:节点 params 中声明了 crop 即需要框选。 const cropNode = (app?.definition?.nodes || []).find( (node) => node.params && node.params.crop !== undefined, ); const needsCrop = Boolean(cropNode); cropNodeId = needsCrop ? cropNode.id : null; ocrCard.hidden = !needsCrop; standardCard.hidden = needsCrop; resetOcrPanel(); if (needsCrop) { document.getElementById("ocrProgress").textContent = "请选择视频并框选字幕区域"; } } // 重置 OCR 面板:清除视频、画布与框选状态。 function resetOcrPanel() { const video = document.getElementById("ocrVideo"); const canvas = document.getElementById("ocrCanvas"); const fileInput = document.getElementById("ocrVideoFile"); const cropValue = document.getElementById("cropValue"); const submit = document.getElementById("ocrSubmit"); selectedCrop = null; if (video) video.removeAttribute("src"); if (canvas) { canvas.width = 0; canvas.height = 0; } if (fileInput) fileInput.value = ""; if (cropValue) cropValue.value = ""; if (submit) submit.disabled = true; selectedVideoFile = null; exitDrawMode(); } // 用 crop.js 把画布上的框选矩形归一化为 crop 比例并展示。 function applyCropRect(rect) { const video = document.getElementById("ocrVideo"); const box = document.getElementById("videoBox"); if (!video.videoWidth || !box) return; const crop = rectToCrop( rect, video.videoWidth, video.videoHeight, box.clientWidth, box.clientHeight, ); selectedCrop = crop; document.getElementById("cropValue").value = crop.join(", "); document.getElementById("ocrSubmit").disabled = false; document.getElementById("ocrProgress").textContent = `已框选 crop=[${crop.join(", ")}],可提交任务`; } // 进入框选模式:暂停视频、隐藏原生控件、启用画布绘制。 function enterDrawMode() { const video = document.getElementById("ocrVideo"); const canvas = document.getElementById("ocrCanvas"); const toggle = document.getElementById("ocrDrawMode"); if (!video.videoWidth) { document.getElementById("ocrProgress").textContent = "请先选择视频并定位到有字幕的画面"; return; } drawMode = true; video.pause(); video.removeAttribute("controls"); // 隐藏进度条,避免遮挡框选操作。 canvas.classList.add("drawable"); if (toggle) toggle.textContent = "退出框选模式"; document.getElementById("ocrProgress").textContent = "请拖动框选字幕区域"; } // 退出框选模式:恢复原生控件,画布不再拦截指针。 function exitDrawMode() { const video = document.getElementById("ocrVideo"); const canvas = document.getElementById("ocrCanvas"); const toggle = document.getElementById("ocrDrawMode"); drawMode = false; if (video) video.setAttribute("controls", ""); if (canvas) canvas.classList.remove("drawable"); if (toggle) toggle.textContent = "进入框选模式"; } // 初始化 OCR 面板:视频预览 + 画布拖动框选。 function setupOcrPanel() { const fileInput = document.getElementById("ocrVideoFile"); const video = document.getElementById("ocrVideo"); const canvas = document.getElementById("ocrCanvas"); const resetButton = document.getElementById("ocrReset"); if (!fileInput || !video || !canvas) return; // 选择视频后显示预览并设置画布尺寸与坐标换算。 // 注意顺序:必须先 reset(会清空旧 src),再设置新 src,否则被清掉。 fileInput.addEventListener("change", () => { const file = fileInput.files[0]; if (!file) return; resetOcrPanel(); // 重置会清空 input,这里把文件引用保存下来供提交使用。 selectedVideoFile = file; video.src = URL.createObjectURL(file); video.load(); video.onloadedmetadata = () => { const box = document.getElementById("videoBox"); canvas.width = box.clientWidth; canvas.height = box.clientHeight; document.getElementById("ocrProgress").textContent = "请在预览中拖动框选字幕区域"; }; }); // 框选模式开关:进入/退出。 const drawToggle = document.getElementById("ocrDrawMode"); if (drawToggle) { drawToggle.addEventListener("click", () => { if (drawMode) { exitDrawMode(); } else { enterDrawMode(); } }); } // 拖动绘制框选矩形:mousedown 起点 → mousemove 更新 → mouseup 生成 crop。 let startX = 0; let startY = 0; let drawing = false; canvas.addEventListener("mousedown", (event) => { if (!drawMode) return; const rect = canvas.getBoundingClientRect(); startX = event.clientX - rect.left; startY = event.clientY - rect.top; drawing = true; }); canvas.addEventListener("mousemove", (event) => { if (!drawing || !drawMode) return; const rect = canvas.getBoundingClientRect(); const x = event.clientX - rect.left; const y = event.clientY - rect.top; const ctx = canvas.getContext("2d"); ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.strokeStyle = "#ff5252"; ctx.lineWidth = 2; ctx.strokeRect(Math.min(startX, x), Math.min(startY, y), Math.abs(x - startX), Math.abs(y - startY)); }); canvas.addEventListener("mouseup", (event) => { if (!drawing || !drawMode) return; drawing = false; const rect = canvas.getBoundingClientRect(); const x = event.clientX - rect.left; const y = event.clientY - rect.top; const w = Math.abs(x - startX); const h = Math.abs(y - startY); if (w < 5 || h < 5) return; applyCropRect({ x: Math.min(startX, x), y: Math.min(startY, y), w, h, }); }); // 清除框选:清空画布与 crop,禁用提交。 resetButton.addEventListener("click", () => { const ctx = canvas.getContext("2d"); ctx.clearRect(0, 0, canvas.width, canvas.height); selectedCrop = null; document.getElementById("cropValue").value = ""; document.getElementById("ocrSubmit").disabled = true; document.getElementById("ocrProgress").textContent = "请在预览中拖动框选字幕区域"; }); } // 提交字幕 OCR 任务:携带 crop 覆盖参数,框选完成才可用。 async function submitOcr() { const fileInput = document.getElementById("ocrVideoFile"); const progress = document.getElementById("ocrProgress"); if (!selectedCrop) { progress.textContent = "请先框选字幕区域"; return; } const file = selectedVideoFile; if (!file) { progress.textContent = "请先选择视频文件"; return; } const workflowId = document.getElementById("workflowSelect").value; const form = new FormData(); form.append("file", file); // 把框选的 crop 传给需要它的节点(如 frame-extract)。 form.append("params", JSON.stringify({ [cropNodeId]: { crop: selectedCrop } })); progress.textContent = "上传中..."; try { const response = await fetch(`/api/apps/${encodeURIComponent(workflowId)}/runs`, { method: "POST", body: form, }); const data = await response.json().catch(() => null); if (!response.ok) { throw new Error(data && data.detail ? JSON.stringify(data.detail) : response.statusText); } progress.textContent = `任务 ${data.id} 已创建,正在跳转到任务管理...`; window.location.href = `/tasks.html?run=${encodeURIComponent(data.id)}`; } catch (error) { progress.textContent = `创建失败:${error.message}`; } } // 渲染单个任务的进度条 HTML;失败任务用红色填充。 function progressBar(percent, failed) { return `
`; } // 为已完成任务生成最终产物下载链接(cn_srt / ass)。 function artifactLinks(runId, status) { if (status !== "COMPLETED") return "-"; return `
中文 SRT VR ASS
`; } // 加载最近任务并渲染任务表格:状态、当前节点、进度条、产物与重试。 async function loadRuns() { const tbody = document.getElementById("runList"); if (!tbody) return; const runs = await api("/api/runs"); tbody.innerHTML = runs.length ? runs .map((run) => { const percent = Math.round((run.progress || 0) * 100); const failed = run.status === "FAILED"; // RUNNING/QUEUED 附加耗时或排队提示,其余状态只显示徽章。 const statusHtml = run.status === "RUNNING" ? `${badge(run.status)} 已运行 ${formatElapsed(run.updated_at)}` : run.status === "QUEUED" ? `${badge(run.status)} 排队中` : run.status === "PAUSED" ? `${badge(run.status)} 已暂停 ${formatElapsed(run.updated_at)}` : badge(run.status); // 失败任务提供重试,所有任务均可删除。 // 排队/运行中可暂停,暂停后可继续,失败可重试,所有任务可删除。 const canPause = run.status === "RUNNING" || run.status === "QUEUED"; const canResume = run.status === "PAUSED"; const actions = ` ${canPause ? `` : ""} ${canResume ? `` : ""} ${failed ? `` : ""} `; return ` ${escapeHtml(run.id)} ${escapeHtml(run.workflow_id)} ${statusHtml} ${escapeHtml(run.current_node_id || "-")} ${progressBar(percent, failed)} ${percent}% ${escapeHtml(formatTime(run.created_at))} ${artifactLinks(run.id, run.status)} ${actions} `; }) .join("") : '暂无任务,请到首页发起。'; } // 请求后端重试失败任务,成功后刷新列表。 async function retryRun(runId) { try { const result = await api(`/api/runs/${runId}/retry`, { method: "POST" }); alert(`任务 ${result.id} 已重新排队`); await loadRuns(); } catch (error) { alert(`重试失败:${error.message}`); } } // 暂停任务:排队或运行中的任务置为 PAUSED,运行中的任务在节点边界停下。 async function pauseRun(runId) { try { const result = await api(`/api/runs/${runId}/pause`, { method: "POST" }); alert(`任务 ${result.id} 已暂停`); await loadRuns(); } catch (error) { alert(`暂停失败:${error.message}`); } } // 继续任务:PAUSED 恢复排队,由调度器从断点继续执行。 async function resumeRun(runId) { try { const result = await api(`/api/runs/${runId}/resume`, { method: "POST" }); alert(`任务 ${result.id} 已恢复执行`); await loadRuns(); } catch (error) { alert(`继续失败:${error.message}`); } } // 删除任务:二次确认后调用后端删除接口并刷新列表。 async function deleteRun(runId) { if (!window.confirm(`确认删除任务 ${runId}?相关产物文件将一并删除。`)) { return; } try { await api(`/api/runs/${runId}`, { method: "DELETE" }); await loadRuns(); } catch (error) { alert(`删除失败:${error.message}`); } } // 当前编辑模式:null=新建;否则为正在编辑的工作流 ID(保存追加新版本)。 let editingWorkflowId = null; // 切换编辑器模式:id 为空进入新建,否则锁定 ID 并提示保存将追加新版本。 function setEditMode(id) { editingWorkflowId = id; const idInput = document.getElementById("workflowId"); const mode = document.getElementById("editMode"); const save = document.getElementById("saveWorkflow"); if (!idInput || !mode || !save) return; if (id) { idInput.value = id; idInput.disabled = true; // 编辑模式下锁定 ID,防止误建新工作流。 mode.textContent = `编辑中:${id} —— 保存将追加为新版本。`; save.textContent = "保存为新版本"; } else { idInput.disabled = false; mode.textContent = "新建工作流:填写 ID 与 DAG 后保存。"; save.textContent = "创建工作流"; } } // 新建模式:清空表单并预填演示 DAG 模板。 function resetWorkflowForm() { document.getElementById("workflowId").value = ""; document.getElementById("workflowName").value = ""; document.getElementById("workflowDescription").value = ""; document.getElementById("workflowDefinition").value = JSON.stringify(DEMO_WORKFLOW, null, 2); setEditMode(null); } // 加载指定工作流的最新定义到编辑器(列表"编辑"按钮)。 // 当前页面没有编辑器表单(如管理后台列表)时跳转到编排页并自动加载。 async function loadWorkflowForEdit(workflowId) { if (!document.getElementById("workflowName")) { window.location.href = `/workflow.html?edit=${encodeURIComponent(workflowId)}`; return; } try { const workflow = await api(`/api/admin/workflows/${workflowId}`); document.getElementById("workflowName").value = workflow.name || ""; document.getElementById("workflowDescription").value = workflow.description || ""; const latest = workflow.latest_version_data; document.getElementById("workflowDefinition").value = latest ? JSON.stringify(latest.definition, null, 2) : JSON.stringify({ name: "", version: 1, nodes: [], edges: [], entry_inputs: {}, final_outputs: {} }, null, 2); setEditMode(workflowId); } catch (error) { alert(`加载失败:${error.message}`); } } // 展示指定工作流的版本历史面板。 async function loadWorkflowVersions(workflowId) { try { const versions = await api(`/api/admin/workflows/${workflowId}/versions`); const tbody = document.getElementById("versionList"); document.getElementById("versionPanelTitle").textContent = `版本历史:${workflowId}`; tbody.innerHTML = versions.length ? versions .map( (version) => ` v${escapeHtml(version.version)} ${escapeHtml(formatTime(version.created_at))} `, ) .join("") : '暂无版本'; document.getElementById("versionPanel").hidden = false; } catch (error) { alert(`版本加载失败:${error.message}`); } } // 关闭版本历史面板。 function closeVersionPanel() { const panel = document.getElementById("versionPanel"); if (panel) panel.hidden = true; } // 把指定工作流的某历史版本定义加载回编辑器(可对比、回滚后保存为新版本)。 async function loadVersionIntoEditor(workflowId, version) { try { const versions = await api(`/api/admin/workflows/${workflowId}/versions`); const target = versions.find((item) => item.version === Number(version)); if (!target) { alert(`版本 v${version} 不存在`); return; } const workflow = await api(`/api/admin/workflows/${workflowId}`); document.getElementById("workflowName").value = workflow.name || ""; document.getElementById("workflowDescription").value = workflow.description || ""; document.getElementById("workflowDefinition").value = JSON.stringify(target.definition, null, 2); setEditMode(workflowId); closeVersionPanel(); } catch (error) { alert(`版本加载失败:${error.message}`); } } // 创建或保存工作流:新建时创建首个版本;编辑时(ID 已锁定)追加新版本。 async function createWorkflow() { const workflowId = document.getElementById("workflowId").value.trim(); const name = document.getElementById("workflowName").value.trim(); const description = document.getElementById("workflowDescription").value.trim(); let definition; try { definition = JSON.parse(document.getElementById("workflowDefinition").value); } catch (error) { alert(`DAG JSON 无效:${error.message}`); return; } try { const result = await api("/api/admin/workflows", { method: "POST", body: JSON.stringify({ id: workflowId || undefined, name, description, definition, }), }); alert(`工作流已保存:${result.id} v${result.latest_version}`); await loadWorkflows(); // 编辑模式保持编辑状态并刷新最新版本;新建模式回到空表单。 if (editingWorkflowId) { await loadWorkflowForEdit(editingWorkflowId); } else { resetWorkflowForm(); } } catch (error) { alert(`保存失败:${error.message}`); } } // 发布指定工作流,使其出现在用户应用中心。 async function publishWorkflow(workflowId) { try { await api(`/api/admin/workflows/${workflowId}/publish`, { method: "POST" }); await loadWorkflows(); } catch (error) { alert(`发布失败:${error.message}`); } } // 删除指定工作流。 async function deleteWorkflow(workflowId) { try { await api(`/api/admin/workflows/${workflowId}`, { method: "DELETE" }); await loadWorkflows(); } catch (error) { alert(`删除失败:${error.message}`); } } // 首页发起任务:选择工作流并上传视频,创建任务后跳转到任务管理页查看进度。 async function uploadVideo() { const workflowId = document.getElementById("workflowSelect").value; const fileInput = document.getElementById("videoFile"); const progress = document.getElementById("runProgress"); if (!workflowId) { progress.textContent = "暂无可用工作流"; return; } if (!fileInput.files.length) { progress.textContent = "请先选择视频文件"; return; } const form = new FormData(); form.append("file", fileInput.files[0]); progress.textContent = "上传中..."; try { const response = await fetch(`/api/apps/${encodeURIComponent(workflowId)}/runs`, { method: "POST", body: form, }); const data = await response.json().catch(() => null); if (!response.ok) { throw new Error(data && data.detail ? JSON.stringify(data.detail) : response.statusText); } progress.textContent = `任务 ${data.id} 已创建,正在跳转到任务管理...`; // 首页只负责发起任务,进度展示交给任务管理页。 window.location.href = `/tasks.html?run=${encodeURIComponent(data.id)}`; } catch (error) { progress.textContent = `创建失败:${error.message}`; } } // 全局点击委托:按按钮上的 data-* 属性分发到对应操作。 document.addEventListener("click", (event) => { const editButton = event.target.closest("[data-edit-workflow]"); if (editButton) { loadWorkflowForEdit(editButton.dataset.editWorkflow); return; } const versionsButton = event.target.closest("[data-versions-workflow]"); if (versionsButton) { loadWorkflowVersions(versionsButton.dataset.versionsWorkflow); return; } const loadVersionButton = event.target.closest("[data-load-version]"); if (loadVersionButton) { const [workflowId, version] = loadVersionButton.dataset.loadVersion.split(":"); loadVersionIntoEditor(workflowId, version); return; } const deleteWorkflowButton = event.target.closest("[data-delete-workflow]"); if (deleteWorkflowButton) { deleteWorkflow(deleteWorkflowButton.dataset.deleteWorkflow); return; } const publishWorkflowButton = event.target.closest("[data-publish-workflow]"); if (publishWorkflowButton) { publishWorkflow(publishWorkflowButton.dataset.publishWorkflow); return; } const retryButton = event.target.closest("[data-retry-run]"); if (retryButton) { retryRun(retryButton.dataset.retryRun); return; } const pauseButton = event.target.closest("[data-pause-run]"); if (pauseButton) { pauseRun(pauseButton.dataset.pauseRun); return; } const resumeButton = event.target.closest("[data-resume-run]"); if (resumeButton) { resumeRun(resumeButton.dataset.resumeRun); return; } const deleteRunButton = event.target.closest("[data-delete-run]"); if (deleteRunButton) { deleteRun(deleteRunButton.dataset.deleteRun); } }); // 页面初始化:预填 DAG、绑定按钮事件并加载对应页面数据。 document.addEventListener("DOMContentLoaded", async () => { // 工作流编辑器:默认进入新建模式并预填演示模板; // 支持 URL 参数 ?edit= 直接加载指定工作流进行编辑。 if (document.getElementById("workflowDefinition")) { resetWorkflowForm(); const params = new URLSearchParams(window.location.search); const editId = params.get("edit"); if (editId) { await loadWorkflowForEdit(editId); } } const saveWorkflowButton = document.getElementById("saveWorkflow"); if (saveWorkflowButton) { saveWorkflowButton.addEventListener("click", createWorkflow); } const resetWorkflowButton = document.getElementById("resetWorkflow"); if (resetWorkflowButton) { resetWorkflowButton.addEventListener("click", resetWorkflowForm); } const closeVersionsButton = document.getElementById("closeVersions"); if (closeVersionsButton) { closeVersionsButton.addEventListener("click", closeVersionPanel); } const publishWorkflowButton = document.getElementById("publishWorkflow"); if (publishWorkflowButton) { publishWorkflowButton.addEventListener("click", () => { publishWorkflow(document.getElementById("workflowId").value.trim()); }); } const uploadButton = document.getElementById("uploadVideo"); if (uploadButton) { uploadButton.addEventListener("click", uploadVideo); // 首页加载已发布工作流供用户选择。 await loadWorkflowOptions(); // 字幕 OCR 面板:视频预览 + 拖动框选(仅需 crop 的工作流展示)。 setupOcrPanel(); } const ocrSubmitButton = document.getElementById("ocrSubmit"); if (ocrSubmitButton) { ocrSubmitButton.addEventListener("click", submitOcr); } await loadHealth(); if (document.getElementById("workflowList")) { await loadWorkflows(); } // 任务管理页:每 3 秒刷新一次全部任务的进度,支持高亮跳转参数 run=ID。 if (document.getElementById("runList")) { await loadRuns(); const params = new URLSearchParams(window.location.search); const target = params.get("run"); if (target) { const row = [...document.querySelectorAll("#runList tr")].find((item) => item.textContent.includes(target), ); if (row) { row.scrollIntoView({ block: "center" }); row.style.background = "#fff7db"; } } setInterval(loadRuns, 3000); } });