feat: 调度器+并发管线+GPU优化+日志分级+前端修复
- scheduler: ffmpeg 异步线程 + GPU 串行调度 + 模型复用(2N→2 次加载) - pipeline: 阶段拆分(extract/asr/translate),中间数据存 Task 字段 - translate_service: 长度排序批处理,padding 浪费减少 91% - model_manager: ASR/翻译不共驻,BatchedInferencePipeline 批量解码 - 日志分级: INFO=任务流转里程碑,DEBUG=进度详情;默认 INFO - 前端: 日志最新在上+滚动感知+退避轮询;24h 时间;上传中状态显示 - /health: 返回完整 Whisper/NLLB 配置 - upload_service: 单事务 complete + 扩展名白名单 - task_router: 合并 UploadSession 虚拟任务到列表 - Dockerfile: CPU/GPU 独立构建链,deps 缓存稳定 - prefetch_models: 安装时预下载模型权重
This commit is contained in:
@@ -202,10 +202,11 @@ a { color: var(--accent); text-decoration: none; }
|
||||
SHARED_JS = """
|
||||
// 任务状态中文标签
|
||||
const STATUS_LABEL = {
|
||||
queued: "排队中", extracting: "提取音频", transcribing: "语音识别",
|
||||
segmenting: "断句重算", translating: "翻译中", done: "完成", failed: "失败"
|
||||
uploading: "上传中", queued: "排队中", extracting: "提取音频",
|
||||
transcribing: "语音识别", segmenting: "断句重算", translating: "翻译中",
|
||||
done: "完成", failed: "失败"
|
||||
};
|
||||
const ACTIVE_STATES = ["queued","extracting","transcribing","segmenting","translating"];
|
||||
const ACTIVE_STATES = ["uploading","queued","extracting","transcribing","segmenting","translating"];
|
||||
|
||||
// HTML 转义(防 XSS)
|
||||
function escapeHtml(s) {
|
||||
@@ -222,6 +223,16 @@ function fmtBytes(n) {
|
||||
return u === 0 ? x + " B" : x.toFixed(1) + " " + units[u];
|
||||
}
|
||||
|
||||
// 24 小时制时间格式化(不受浏览器 locale 影响,避免 am/pm 混淆)
|
||||
function _pad2(n) { return n < 10 ? "0" + n : "" + n; }
|
||||
function fmtTime24(d) {
|
||||
return _pad2(d.getHours()) + ":" + _pad2(d.getMinutes()) + ":" + _pad2(d.getSeconds());
|
||||
}
|
||||
function fmtDateTime24(d) {
|
||||
return d.getFullYear() + "-" + _pad2(d.getMonth()+1) + "-" + _pad2(d.getDate())
|
||||
+ " " + fmtTime24(d);
|
||||
}
|
||||
|
||||
// 并发池:indices 中的每个元素交给 worker,最多 concurrency 个并发
|
||||
async function runPool(indices, concurrency, worker) {
|
||||
let cursor = 0;
|
||||
|
||||
@@ -68,7 +68,11 @@ function renderTable(tasks) {{
|
||||
const tr = document.createElement("tr");
|
||||
const label = STATUS_LABEL[task.status] || task.status;
|
||||
const stateClass = "st-" + (task.status === "done" ? "done" : task.status === "failed" ? "fail" : "running");
|
||||
const created = new Date(task.created_at).toLocaleString();
|
||||
let created;
|
||||
try {{ created = fmtDateTime24(new Date(task.created_at + "Z")); }}
|
||||
catch (e) {{ created = task.created_at; }}
|
||||
|
||||
let idDisplay = task.is_upload ? "—" : "#" + task.id;
|
||||
|
||||
let action;
|
||||
if (task.status === "done") {{
|
||||
@@ -84,10 +88,13 @@ function renderTable(tasks) {{
|
||||
let progress;
|
||||
if (task.status === "done") progress = "100%";
|
||||
else if (task.status === "failed") progress = "—";
|
||||
else progress = `<div class="mini-bar"><div class="mini-fill" style="width:${{task.progress}}%"></div></div>${{task.progress.toFixed(0)}}%`;
|
||||
else {{
|
||||
const pct = (task.progress == null) ? 0 : task.progress;
|
||||
progress = `<div class="mini-bar"><div class="mini-fill" style="width:${{pct}}%"></div></div>${{pct.toFixed(0)}}%`;
|
||||
}}
|
||||
|
||||
tr.innerHTML = `
|
||||
<td class="muted">#${{task.id}}</td>
|
||||
<td class="muted">${{idDisplay}}</td>
|
||||
<td>${{escapeHtml(task.filename)}}</td>
|
||||
<td><span class="status-tag ${{stateClass}}">${{label}}</span></td>
|
||||
<td>${{progress}}</td>
|
||||
|
||||
@@ -42,12 +42,13 @@ function renderTasks(tasks) {{
|
||||
for (const task of tasks) {{
|
||||
tasksEl.appendChild(makeTaskCard(task));
|
||||
}}
|
||||
// 对进行中的任务启动轮询
|
||||
// 对进行中的任务启动轮询(上传会话用 upload_id 去重,Task 用 id 去重)
|
||||
for (const task of tasks) {{
|
||||
if (ACTIVE_STATES.includes(task.status) && !pollingIds.has(task.id)) {{
|
||||
pollingIds.add(task.id);
|
||||
pollTask(task.id);
|
||||
}}
|
||||
if (!ACTIVE_STATES.includes(task.status)) continue;
|
||||
const pollKey = task.is_upload ? task.upload_id : String(task.id);
|
||||
if (pollingIds.has(pollKey)) continue;
|
||||
pollingIds.add(pollKey);
|
||||
pollTask(task);
|
||||
}}
|
||||
}}
|
||||
|
||||
@@ -61,6 +62,7 @@ function makeTaskCard(task) {{
|
||||
const el = document.createElement("div");
|
||||
el.className = "card task server-task";
|
||||
el.dataset.taskId = task.id;
|
||||
if (task.is_upload) el.dataset.uploadId = task.upload_id;
|
||||
el.innerHTML = renderTaskInner(task);
|
||||
return el;
|
||||
}}
|
||||
@@ -69,7 +71,11 @@ function renderTaskInner(task) {{
|
||||
const label = STATUS_LABEL[task.status] || task.status;
|
||||
const stateClass = task.status === "done" ? "state-done"
|
||||
: task.status === "failed" ? "state-fail" : "state-running";
|
||||
const created = new Date(task.created_at).toLocaleString();
|
||||
|
||||
// 上传中:created_at 可能是 naive UTC,前端按 UTC 解析
|
||||
let created;
|
||||
try {{ created = fmtDateTime24(new Date(task.created_at + "Z")); }}
|
||||
catch (e) {{ created = task.created_at; }}
|
||||
|
||||
let body;
|
||||
if (task.status === "done") {{
|
||||
@@ -77,35 +83,72 @@ function renderTaskInner(task) {{
|
||||
}} else if (task.status === "failed") {{
|
||||
body = `<div class="task-meta fail-msg">${{escapeHtml(task.error || "未知错误")}}</div>`;
|
||||
}} else {{
|
||||
body = `<div class="bar"><div class="fill" style="width:${{task.progress}}%"></div><span class="pct">${{task.progress.toFixed(0)}}%</span></div>`;
|
||||
const pct = (task.progress == null) ? 0 : task.progress;
|
||||
const sizeInfo = task.size_bytes ? ` · ${{fmtBytes(task.size_bytes)}}` : "";
|
||||
body = `<div class="bar"><div class="fill" style="width:${{pct}}%"></div><span class="pct">${{pct.toFixed(0)}}%${{sizeInfo}}</span></div>`;
|
||||
}}
|
||||
return `
|
||||
<div class="task-head">
|
||||
<span class="fname">#${{task.id}} ${{escapeHtml(task.filename)}}</span>
|
||||
<span class="fname">${{task.is_upload ? "" : "#" + task.id + " "}}${{escapeHtml(task.filename)}}</span>
|
||||
<span class="fstate ${{stateClass}}">${{label}}</span>
|
||||
</div>
|
||||
${{body}}
|
||||
<div class="task-time">${{created}}</div>`;
|
||||
}}
|
||||
|
||||
async function pollTask(taskId) {{
|
||||
async function pollTask(task) {{
|
||||
// 上传会话:轮询 upload status 接口;Task:轮询 task 接口
|
||||
const isUpload = task.is_upload === true && task.upload_id;
|
||||
const url = isUpload
|
||||
? "/api/tasks/chunk-uploads/" + task.upload_id + "/status"
|
||||
: "/api/tasks/" + task.id;
|
||||
let backoff = POLL_INTERVAL;
|
||||
const tick = async () => {{
|
||||
try {{
|
||||
const r = await fetch("/api/tasks/" + taskId);
|
||||
if (!r.ok) {{ pollingIds.delete(taskId); return; }}
|
||||
const task = await r.json();
|
||||
const card = tasksEl.querySelector('.server-task[data-task-id="' + taskId + '"]');
|
||||
if (!card) {{ pollingIds.delete(taskId); return; }}
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) {{ pollingIds.delete(task.id); pollingIds.delete(task.upload_id); return; }}
|
||||
const data = await r.json();
|
||||
|
||||
card.innerHTML = renderTaskInner(task);
|
||||
|
||||
if (task.status === "done" || task.status === "failed") {{
|
||||
pollingIds.delete(taskId);
|
||||
return;
|
||||
if (isUpload) {{
|
||||
// 上传会话:complete 后 task_id 出现,切换为 Task 轮询
|
||||
if (data.completed && data.task_id) {{
|
||||
pollingIds.delete(task.upload_id);
|
||||
pollingIds.add(data.task_id);
|
||||
pollTask({{ id: data.task_id, is_upload: false }});
|
||||
// 刷新列表让新 Task 卡片出现
|
||||
refreshList();
|
||||
return;
|
||||
}}
|
||||
// 更新上传进度
|
||||
const card = tasksEl.querySelector('.server-task[data-upload-id="' + task.upload_id + '"]');
|
||||
if (!card) {{ pollingIds.delete(task.upload_id); return; }}
|
||||
const uploaded = (data.uploaded_chunks || []).length;
|
||||
const total = data.total_chunks || 1;
|
||||
const pct = Math.min(4, uploaded / total * 4);
|
||||
const fakeTask = {{
|
||||
status: "uploading", progress: pct, is_upload: true,
|
||||
upload_id: task.upload_id, filename: data.filename,
|
||||
size_bytes: data.size_bytes, created_at: task.created_at,
|
||||
}};
|
||||
card.innerHTML = renderTaskInner(fakeTask);
|
||||
backoff = POLL_INTERVAL;
|
||||
setTimeout(tick, backoff);
|
||||
}} else {{
|
||||
// Task 轮询
|
||||
const card = tasksEl.querySelector('.server-task[data-task-id="' + task.id + '"]');
|
||||
if (!card) {{ pollingIds.delete(task.id); return; }}
|
||||
card.innerHTML = renderTaskInner(data);
|
||||
if (data.status === "done" || data.status === "failed") {{
|
||||
pollingIds.delete(task.id);
|
||||
return;
|
||||
}}
|
||||
backoff = POLL_INTERVAL;
|
||||
setTimeout(tick, backoff);
|
||||
}}
|
||||
setTimeout(tick, POLL_INTERVAL);
|
||||
}} catch (e) {{
|
||||
setTimeout(tick, POLL_INTERVAL);
|
||||
// 网络错误:指数退避,上限 30s
|
||||
backoff = Math.min(backoff * 1.6, 30000);
|
||||
setTimeout(tick, backoff);
|
||||
}}
|
||||
}};
|
||||
tick();
|
||||
|
||||
@@ -2,25 +2,34 @@
|
||||
|
||||
共享 _shared.py 的 BASE_CSS / SHARED_JS。
|
||||
页面专属:级别过滤按钮、自动刷新开关、清空、traceback 折叠。
|
||||
|
||||
显示策略:
|
||||
- 默认 INFO(仅阶段转换/模型加载卸载/任务流转开始结束),可切 DEBUG 看进度详情。
|
||||
- 最新日志在顶部;用户向上滚动浏览历史时不会被自动刷新拉走(仅当停在顶部时跟随)。
|
||||
- 轮询带指数退避,连续失败时拉长间隔,避免服务不可达时打爆。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ._shared import render_page
|
||||
|
||||
POLL_INTERVAL_LOGS = 2000
|
||||
POLL_INTERVAL_LOGS = 2000 # 正常轮询间隔(ms)
|
||||
POLL_MAX_INTERVAL = 30000 # 退避上限(ms)
|
||||
DEFAULT_TAIL = 200
|
||||
|
||||
_PAGE_JS = f"""
|
||||
const POLL_INTERVAL = {POLL_INTERVAL_LOGS};
|
||||
const POLL_MIN_INTERVAL = {POLL_INTERVAL_LOGS};
|
||||
const POLL_MAX_INTERVAL = {POLL_MAX_INTERVAL};
|
||||
const DEFAULT_TAIL = {DEFAULT_TAIL};
|
||||
const API = "/api/logs";
|
||||
|
||||
let currentLevel = "debug";
|
||||
let currentLevel = "info"; // 默认 INFO
|
||||
let autoRefresh = true;
|
||||
let timer = null;
|
||||
let pollInterval = POLL_MIN_INTERVAL; // 动态退避
|
||||
let userScrolled = false; // 用户是否主动向下浏览历史
|
||||
|
||||
const logsEl = document.getElementById("logs");
|
||||
const containerEl = document.getElementById("logs");
|
||||
const emptyEl = document.getElementById("empty");
|
||||
const statusEl = document.getElementById("status");
|
||||
|
||||
@@ -29,26 +38,34 @@ const LEVEL_CLASS = {{
|
||||
CRITICAL: "st-fail"
|
||||
}};
|
||||
|
||||
// 监听滚动:用户向下(往历史方向)浏览时暂停自动跟随,回到顶部则恢复
|
||||
containerEl.addEventListener("scroll", () => {{
|
||||
// scrollTop 越小越靠近顶部(最新)。接近顶部 = 用户在看最新
|
||||
userScrolled = containerEl.scrollTop > 4;
|
||||
}});
|
||||
|
||||
document.querySelectorAll(".filter").forEach(btn => {{
|
||||
btn.addEventListener("click", () => {{
|
||||
document.querySelectorAll(".filter").forEach(b => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
currentLevel = btn.dataset.level;
|
||||
logsEl.innerHTML = "";
|
||||
containerEl.innerHTML = "";
|
||||
pollInterval = POLL_MIN_INTERVAL;
|
||||
fetchLogs();
|
||||
}});
|
||||
}});
|
||||
|
||||
document.getElementById("autorefresh").addEventListener("change", e => {{
|
||||
autoRefresh = e.target.checked;
|
||||
if (autoRefresh) fetchLogs(); else if (timer) {{ clearTimeout(timer); timer = null; }}
|
||||
if (autoRefresh) {{ pollInterval = POLL_MIN_INTERVAL; fetchLogs(); }}
|
||||
else if (timer) {{ clearTimeout(timer); timer = null; }}
|
||||
}});
|
||||
|
||||
document.getElementById("clear-btn").addEventListener("click", async () => {{
|
||||
if (!confirm("确定清空所有日志缓冲?")) return;
|
||||
try {{
|
||||
await fetch(API, {{ method: "DELETE" }});
|
||||
logsEl.innerHTML = "";
|
||||
containerEl.innerHTML = "";
|
||||
statusEl.textContent = "已清空";
|
||||
}} catch (e) {{ statusEl.textContent = "清空失败"; }}
|
||||
}});
|
||||
@@ -56,57 +73,87 @@ document.getElementById("clear-btn").addEventListener("click", async () => {{
|
||||
async function fetchLogs() {{
|
||||
try {{
|
||||
const r = await fetch(`${{API}}?level=${{currentLevel}}&tail=${{DEFAULT_TAIL}}`);
|
||||
if (!r.ok) {{ statusEl.textContent = "HTTP " + r.status; scheduleNext(); return; }}
|
||||
if (!r.ok) {{ statusEl.textContent = "HTTP " + r.status; scheduleNext(true); return; }}
|
||||
const data = await r.json();
|
||||
renderLogs(data.logs);
|
||||
statusEl.textContent = `${{data.count}} 条 · 更新 ${{new Date().toLocaleTimeString()}}`;
|
||||
scheduleNext();
|
||||
statusEl.textContent = `${{data.count}} 条 · 更新 ${{fmtTime24(new Date())}}`;
|
||||
pollInterval = POLL_MIN_INTERVAL; // 成功,重置间隔
|
||||
scheduleNext(false);
|
||||
}} catch (e) {{
|
||||
statusEl.textContent = "获取失败";
|
||||
scheduleNext();
|
||||
scheduleNext(true); // 失败,退避
|
||||
}}
|
||||
}}
|
||||
|
||||
function renderLogs(logs) {{
|
||||
if (!logs || logs.length === 0) {{
|
||||
if (logsEl.children.length === 0) emptyEl.style.display = "block";
|
||||
if (containerEl.children.length === 0) emptyEl.style.display = "block";
|
||||
return;
|
||||
}}
|
||||
emptyEl.style.display = "none";
|
||||
const existing = new Set(Array.from(logsEl.children).map(el => el.dataset.key));
|
||||
|
||||
// 服务端已按时间升序返回(旧→新)。倒序渲染:最新在最上面。
|
||||
// 用 key=ts|level|logger|msg 去重(含 logger 名,避免不同模块同消息被误判重复)
|
||||
const existing = new Set(Array.from(containerEl.children).map(el => el.dataset.key));
|
||||
const wasAtTop = containerEl.scrollTop <= 4;
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const log of logs) {{
|
||||
const key = `${{log.ts}}|${{log.level}}|${{log.msg}}`;
|
||||
|
||||
// 服务端按时间升序返回(旧→新)。倒序遍历 + appendChild → frag 内最新在前:
|
||||
// logs[N-1](最新)先 append → frag 第一个;logs[0](最旧)最后 → frag 最后
|
||||
for (let i = logs.length - 1; i >= 0; i--) {{
|
||||
const log = logs[i];
|
||||
const key = `${{log.ts}}|${{log.level}}|${{log.logger}}|${{log.msg}}`;
|
||||
if (existing.has(key)) continue;
|
||||
const row = document.createElement("div");
|
||||
row.className = "log-row " + (LEVEL_CLASS[log.level] || "st-running");
|
||||
row.dataset.key = key;
|
||||
const hasTrace = !!log.traceback;
|
||||
row.innerHTML = `
|
||||
<span class="log-ts">${{escapeHtml(log.ts)}}</span>
|
||||
<span class="status-tag ${{LEVEL_CLASS[log.level] || 'st-running'}}">${{escapeHtml(log.level)}}</span>
|
||||
<span class="log-logger">${{escapeHtml(log.logger)}}</span>
|
||||
<span class="log-msg">${{escapeHtml(log.msg)}}${{hasTrace ? ' <span class="trace-toggle">[traceback]</span>' : ''}}</span>`;
|
||||
if (hasTrace) {{
|
||||
const pre = document.createElement("pre");
|
||||
pre.className = "trace";
|
||||
pre.textContent = log.traceback;
|
||||
pre.style.display = "none";
|
||||
row.appendChild(pre);
|
||||
row.querySelector(".trace-toggle").addEventListener("click", e => {{
|
||||
e.stopPropagation();
|
||||
pre.style.display = pre.style.display === "none" ? "block" : "none";
|
||||
}});
|
||||
}}
|
||||
frag.appendChild(row);
|
||||
frag.appendChild(buildRow(log, key));
|
||||
}}
|
||||
|
||||
if (frag.children.length > 0) {{
|
||||
// 整块插入到容器顶部:最新日志在最上面
|
||||
containerEl.insertBefore(frag, containerEl.firstChild);
|
||||
}}
|
||||
|
||||
// 裁剪:超出上限删最旧(底部)
|
||||
while (containerEl.children.length > DEFAULT_TAIL) {{
|
||||
containerEl.removeChild(containerEl.lastChild);
|
||||
}}
|
||||
|
||||
// 仅当用户停在顶部(看最新)时保持滚定在顶,否则不打扰浏览历史
|
||||
if (wasAtTop && !userScrolled) {{
|
||||
containerEl.scrollTop = 0;
|
||||
}}
|
||||
logsEl.appendChild(frag);
|
||||
while (logsEl.children.length > DEFAULT_TAIL) logsEl.removeChild(logsEl.firstChild);
|
||||
logsEl.scrollTop = logsEl.scrollHeight;
|
||||
}}
|
||||
|
||||
function scheduleNext() {{
|
||||
if (autoRefresh) timer = setTimeout(fetchLogs, POLL_INTERVAL);
|
||||
function buildRow(log, key) {{
|
||||
const row = document.createElement("div");
|
||||
row.className = "log-row " + (LEVEL_CLASS[log.level] || "st-running");
|
||||
row.dataset.key = key;
|
||||
const hasTrace = !!log.traceback;
|
||||
row.innerHTML = `
|
||||
<span class="log-ts">${{escapeHtml(log.ts)}}</span>
|
||||
<span class="status-tag ${{LEVEL_CLASS[log.level] || 'st-running'}}">${{escapeHtml(log.level)}}</span>
|
||||
<span class="log-logger">${{escapeHtml(log.logger)}}</span>
|
||||
<span class="log-msg">${{escapeHtml(log.msg)}}${{hasTrace ? ' <span class="trace-toggle">[traceback]</span>' : ''}}</span>`;
|
||||
if (hasTrace) {{
|
||||
const pre = document.createElement("pre");
|
||||
pre.className = "trace";
|
||||
pre.textContent = log.traceback;
|
||||
pre.style.display = "none";
|
||||
row.appendChild(pre);
|
||||
row.querySelector(".trace-toggle").addEventListener("click", e => {{
|
||||
e.stopPropagation();
|
||||
pre.style.display = pre.style.display === "none" ? "block" : "none";
|
||||
}});
|
||||
}}
|
||||
return row;
|
||||
}}
|
||||
|
||||
function scheduleNext(failed) {{
|
||||
if (!autoRefresh) return;
|
||||
if (failed) {{
|
||||
// 指数退避:每次失败 ×1.6,上限 30s
|
||||
pollInterval = Math.min(pollInterval * 1.6, POLL_MAX_INTERVAL);
|
||||
}}
|
||||
timer = setTimeout(fetchLogs, pollInterval);
|
||||
}}
|
||||
|
||||
fetchLogs();
|
||||
@@ -136,12 +183,12 @@ _PAGE_CSS = """
|
||||
|
||||
_BODY = """
|
||||
<h1>日志</h1>
|
||||
<p class="sub">实时查看服务日志。debug=详细子步骤,info=仅阶段转换,error=完整错误。自动刷新每 2 秒。</p>
|
||||
<p class="sub">默认显示 INFO(任务流转/模型加载卸载)。切 DEBUG 看进度详情,切 警告+/仅错误 过滤问题。自动刷新每 2 秒,失败自动退避。最新日志在顶部,向下浏览历史时不会被拉走。</p>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="filters">
|
||||
<button class="filter active" data-level="debug">全部 (DEBUG)</button>
|
||||
<button class="filter" data-level="info">简略 (INFO)</button>
|
||||
<button class="filter" data-level="debug">全部 (DEBUG)</button>
|
||||
<button class="filter active" data-level="info">简略 (INFO)</button>
|
||||
<button class="filter" data-level="warning">警告+</button>
|
||||
<button class="filter" data-level="error">仅错误</button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user