"""日志页:实时轮询 /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 = ` ${{escapeHtml(log.ts)}} ${{escapeHtml(log.level)}} ${{escapeHtml(log.logger)}} ${{escapeHtml(log.msg)}}${{hasTrace ? ' [traceback]' : ''}}`; 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 = """
默认显示 INFO(任务流转/模型加载卸载)。切 DEBUG 看进度详情,切 警告+/仅错误 过滤问题。自动刷新每 2 秒,失败自动退避。最新日志在顶部,向下浏览历史时不会被拉走。