Files
audio2text/app/views/logs_html.py
audio2text dev 73110848f4 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: 安装时预下载模型权重
2026-07-06 21:59:59 +08:00

215 lines
8.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""日志页:实时轮询 /api/logs按级别过滤可展开 traceback。
共享 _shared.py 的 BASE_CSS / SHARED_JS。
页面专属级别过滤按钮、自动刷新开关、清空、traceback 折叠。
显示策略:
- 默认 INFO仅阶段转换/模型加载卸载/任务流转开始结束),可切 DEBUG 看进度详情。
- 最新日志在顶部;用户向上滚动浏览历史时不会被自动刷新拉走(仅当停在顶部时跟随)。
- 轮询带指数退避,连续失败时拉长间隔,避免服务不可达时打爆。
"""
from __future__ import annotations
from ._shared import render_page
POLL_INTERVAL_LOGS = 2000 # 正常轮询间隔ms
POLL_MAX_INTERVAL = 30000 # 退避上限ms
DEFAULT_TAIL = 200
_PAGE_JS = f"""
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 = "info"; // 默认 INFO
let autoRefresh = true;
let timer = null;
let pollInterval = POLL_MIN_INTERVAL; // 动态退避
let userScrolled = false; // 用户是否主动向下浏览历史
const containerEl = document.getElementById("logs");
const emptyEl = document.getElementById("empty");
const statusEl = document.getElementById("status");
const LEVEL_CLASS = {{
DEBUG: "st-running", INFO: "st-done", WARNING: "st-fail", ERROR: "st-fail",
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;
containerEl.innerHTML = "";
pollInterval = POLL_MIN_INTERVAL;
fetchLogs();
}});
}});
document.getElementById("autorefresh").addEventListener("change", e => {{
autoRefresh = e.target.checked;
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" }});
containerEl.innerHTML = "";
statusEl.textContent = "已清空";
}} catch (e) {{ statusEl.textContent = "清空失败"; }}
}});
async function fetchLogs() {{
try {{
const r = await fetch(`${{API}}?level=${{currentLevel}}&tail=${{DEFAULT_TAIL}}`);
if (!r.ok) {{ statusEl.textContent = "HTTP " + r.status; scheduleNext(true); return; }}
const data = await r.json();
renderLogs(data.logs);
statusEl.textContent = `${{data.count}} 条 · 更新 ${{fmtTime24(new Date())}}`;
pollInterval = POLL_MIN_INTERVAL; // 成功,重置间隔
scheduleNext(false);
}} catch (e) {{
statusEl.textContent = "获取失败";
scheduleNext(true); // 失败,退避
}}
}}
function renderLogs(logs) {{
if (!logs || logs.length === 0) {{
if (containerEl.children.length === 0) emptyEl.style.display = "block";
return;
}}
emptyEl.style.display = "none";
// 服务端已按时间升序返回(旧→新)。倒序渲染:最新在最上面。
// 用 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();
// 服务端按时间升序返回(旧→新)。倒序遍历 + 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;
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;
}}
}}
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();
"""
_PAGE_CSS = """
.logs { border: 1px solid var(--border); border-radius: 10px; max-height: 70vh;
overflow-y: auto; background: var(--card-bg);
font-family: "SF Mono", "Cascadia Code", Consolas, monospace; font-size: 0.82em; }
.log-row { display: grid; grid-template-columns: 140px 70px 150px 1fr; gap: 0.5em;
padding: 0.25em 0.8em; border-bottom: 1px solid var(--border); align-items: start; }
.log-row:hover { background: var(--accent-light); }
.log-ts { color: var(--muted); white-space: nowrap; }
.log-logger { color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.log-msg { word-break: break-all; white-space: pre-wrap; }
.trace-toggle { color: var(--error); cursor: pointer; font-weight: 600; font-size: 0.85em; }
.trace { grid-column: 1 / -1; margin: 0.3em 0 0.5em; padding: 0.6em; background: #1e1e1e;
color: #f44336; border-radius: 4px; font-size: 0.9em; overflow-x: auto; white-space: pre-wrap; }
.filters { display: flex; gap: 0.4em; }
.filter { padding: 0.3em 1em; border: 1px solid var(--border); border-radius: 14px;
background: transparent; cursor: pointer; font-size: 0.85em; color: var(--muted);
transition: background 0.15s, color 0.15s; }
.filter.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.filter:hover:not(.active) { background: var(--accent-light); }
.toggle { display: flex; align-items: center; gap: 0.3em; cursor: pointer; color: var(--muted); }
"""
_BODY = """
<h1>日志</h1>
<p class="sub">默认显示 INFO任务流转/模型加载卸载)。切 DEBUG 看进度详情,切 警告+/仅错误 过滤问题。自动刷新每 2 秒,失败自动退避。最新日志在顶部,向下浏览历史时不会被拉走。</p>
<div class="toolbar">
<div class="filters">
<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>
<div style="display:flex;align-items:center;gap:0.8em;">
<label class="toggle"><input id="autorefresh" type="checkbox" checked> 自动刷新</label>
<button id="clear-btn" class="btn-sm">清空</button>
<span id="status" class="info"></span>
</div>
</div>
<div id="logs" class="logs"></div>
<div id="empty" class="empty">暂无日志。</div>
"""
def render() -> str:
return render_page(
title="日志 — audio2text",
nav_active="logs",
body=_BODY,
page_js=_PAGE_JS,
page_css=_PAGE_CSS,
)