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:
audio2text dev
2026-07-06 21:59:59 +08:00
parent 00e2a95fb7
commit 73110848f4
30 changed files with 1238 additions and 259 deletions

View File

@@ -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>