Files
workspace/code/g3soft-erp/server/log-viewer.html
T
2026-10-13 17:29:55 +08:00

415 lines
18 KiB
HTML
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.
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>ERP 日志分析</title>
<style>
:root { --bd:#dfe3e8; --mut:#6b7280; --bg:#f7f8fa; --err:#c0392b; --warn:#b7791f; --ok:#2f855a; --hi:#2563eb; }
* { box-sizing:border-box; }
body { margin:0; font:14px/1.5 "Microsoft YaHei",system-ui,Segoe UI,Arial,sans-serif; color:#1f2937; background:var(--bg); }
header { position:sticky; top:0; z-index:10; background:#fff; border-bottom:1px solid var(--bd); padding:14px 18px; }
h1 { margin:0 0 4px; font-size:18px; }
h2 { margin:22px 0 8px; font-size:15px; border-left:4px solid var(--hi); padding-left:8px; }
.hint { margin:0 0 10px; color:var(--mut); font-size:12px; }
.controls { display:flex; flex-wrap:wrap; gap:10px; align-items:center; }
.controls label { color:var(--mut); font-size:13px; }
.btn { border:1px solid var(--bd); background:#fff; padding:6px 12px; border-radius:6px; cursor:pointer; font-size:13px; }
.btn:hover { border-color:var(--hi); color:var(--hi); }
input[type=number] { width:70px; padding:4px 6px; border:1px solid var(--bd); border-radius:4px; }
input[type=text] { padding:6px 8px; border:1px solid var(--bd); border-radius:4px; width:260px; }
#drop { margin:12px 18px 0; border:2px dashed var(--bd); border-radius:10px; padding:18px; text-align:center; color:var(--mut); background:#fff; }
#drop.on { border-color:var(--hi); background:#eef4ff; color:var(--hi); }
main { padding:0 18px 40px; }
#status { color:var(--mut); font-size:13px; }
.cards { display:flex; flex-wrap:wrap; gap:10px; margin-top:14px; }
.card { flex:1 1 140px; background:#fff; border:1px solid var(--bd); border-radius:8px; padding:10px 12px; }
.card .n { font-size:20px; font-weight:600; }
.card .t { color:var(--mut); font-size:12px; }
.card.bad .n { color:var(--err); }
.card.warn .n { color:var(--warn); }
table { border-collapse:collapse; width:100%; background:#fff; font-size:13px; }
th,td { border:1px solid var(--bd); padding:5px 8px; text-align:left; vertical-align:top; }
th { background:#f1f3f5; font-weight:600; white-space:nowrap; }
tbody tr:nth-child(even) { background:#fafbfc; }
td.num { text-align:right; font-variant-numeric:tabular-nums; }
.empty { color:var(--mut); font-size:13px; padding:8px 0; }
.tag { display:inline-block; padding:0 6px; border-radius:10px; font-size:12px; border:1px solid var(--bd); }
.lvl-ERROR { color:#fff; background:var(--err); border-color:var(--err); }
.lvl-WARN { color:#fff; background:var(--warn); border-color:var(--warn); }
.lvl-INFO { color:#fff; background:var(--ok); border-color:var(--ok); }
.lvl-DEBUG { color:#fff; background:#6b7280; border-color:#6b7280; }
.lnk { color:var(--hi); cursor:pointer; text-decoration:underline; word-break:break-all; }
.mono { font-family:Consolas,Menlo,monospace; }
.params { max-width:460px; word-break:break-all; font-family:Consolas,Menlo,monospace; font-size:12px; color:#334155; }
.st { white-space:pre-wrap; font-family:Consolas,Menlo,monospace; font-size:12px; color:#7f1d1d; margin-top:4px; display:none; }
details > summary { cursor:pointer; color:var(--hi); }
.scroll { max-height:420px; overflow:auto; border:1px solid var(--bd); border-radius:6px; }
.scroll table { border:0; }
footer { color:var(--mut); font-size:12px; padding:8px 18px 24px; }
</style>
</head>
<body>
<header>
<h1>ERP 日志分析</h1>
<p class="hint">双击本文件用浏览器打开(推荐 Edge / Chrome)→ 选择日志文件或整个 logs 文件夹,也可以直接把文件拖进来。</p>
<div class="controls">
<input type="file" id="pickFiles" multiple hidden>
<input type="file" id="pickDir" webkitdirectory hidden>
<button class="btn" id="btnFiles">选择日志文件</button>
<button class="btn" id="btnDir">选择 logs 文件夹</button>
<label>慢请求阈值 <input type="number" id="slowMs" value="1000" min="0" step="100"> ms</label>
<label>Top <input type="number" id="topN" value="50" min="1" step="10"></label>
<span id="status"></span>
</div>
</header>
<div id="drop">把 <span class="mono">.jsonl</span> / <span class="mono">.jsonl.gz</span> 日志文件或整个 logs 文件夹拖到这里</div>
<main id="out"></main>
<footer>数据只在浏览器本地解析,不会上传任何地方。</footer>
<script>
"use strict";
var rows = []; // 解析后的日志对象
var badLines = 0; // 解析失败的行数
var fileCount = 0; // 读取的文件数
var traceQuery = ""; // 当前 traceId 查询
var $ = function (id) { return document.getElementById(id); };
/* ---------- 读取文件(含 .gz 解压) ---------- */
function readAsText(file) {
var stream = file.stream();
if (/\.gz$/i.test(file.name)) {
if (typeof DecompressionStream === "undefined") {
return Promise.reject(new Error("当前浏览器不支持 gzip 解压,请用最新版 Edge/Chrome"));
}
stream = stream.pipeThrough(new DecompressionStream("gzip"));
}
return new Response(stream).text();
}
function isLogFile(name) {
return /\.(jsonl|ndjson|gz|log|txt)$/i.test(name);
}
async function load(files) {
var list = Array.prototype.slice.call(files).filter(function (f) { return isLogFile(f.name); });
var skipped = files.length - list.length;
rows = []; badLines = 0; fileCount = 0; traceQuery = "";
var problems = [];
for (var i = 0; i < list.length; i++) {
var f = list[i];
try {
var text = await readAsText(f);
ingest(text);
fileCount++;
} catch (e) {
problems.push(f.name + ": " + e.message);
}
}
var msg = "已读取 " + fileCount + " 个文件," + rows.length + " 条日志";
if (badLines) msg += "(" + badLines + " 行无法解析已跳过)";
if (skipped) msg += ";忽略 " + skipped + " 个非日志文件";
if (problems.length) msg += ";失败: " + problems.join(";");
$("status").textContent = msg;
render();
}
function ingest(text) {
var lines = text.split(/\r?\n/);
for (var i = 0; i < lines.length; i++) {
var s = lines[i].trim();
if (!s) continue;
try { rows.push(JSON.parse(s)); } catch (e) { badLines++; }
}
}
/* ---------- 小工具 ---------- */
function esc(v) {
if (v === null || v === undefined) return "";
return String(v).replace(/[&<>"]/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c];
});
}
function groupCount(arr, keyFn) {
var m = new Map();
arr.forEach(function (r) {
var k = keyFn(r);
if (k === null || k === undefined || k === "") k = "(空)";
m.set(k, (m.get(k) || 0) + 1);
});
return Array.from(m.entries()).sort(function (a, b) { return b[1] - a[1]; });
}
function isAccess(r) { return r.logger_name === "ACCESS"; }
function slowLimit() { var v = parseInt($("slowMs").value, 10); return isNaN(v) ? 1000 : v; }
function topLimit() { var v = parseInt($("topN").value, 10); return isNaN(v) ? 50 : v; }
function traceLink(tid) { return '<span class="lnk mono" data-trace="' + esc(tid) + '">' + esc(tid) + '</span>'; }
function tableHTML(headers, bodyRows) {
var h = "<table><thead><tr>";
headers.forEach(function (x) { h += "<th>" + x + "</th>"; });
h += "</tr></thead><tbody>";
if (!bodyRows.length) {
h += '<tr><td colspan="' + headers.length + '" class="empty">无数据</td></tr>';
} else {
bodyRows.forEach(function (r) {
h += "<tr>";
r.forEach(function (c) { h += "<td>" + c + "</td>"; });
h += "</tr>";
});
}
return h + "</tbody></table>";
}
/* ---------- 渲染 ---------- */
function render() {
var out = $("out");
if (!rows.length) {
out.innerHTML = '<h2>概览</h2><div class="empty">还没有数据。请选择或拖入日志文件。</div>';
return;
}
var access = rows.filter(isAccess);
var errors = rows.filter(function (r) { return r.level === "ERROR"; });
var warns = rows.filter(function (r) { return r.level === "WARN"; });
var lim = slowLimit();
var slow = access.filter(function (r) { return (Number(r.durationMs) || 0) > lim; })
.sort(function (a, b) { return (Number(b.durationMs) || 0) - (Number(a.durationMs) || 0); });
var times = rows.map(function (r) { return r["@timestamp"] || ""; }).filter(Boolean).sort();
var tMin = times.length ? times[0].replace("T", " ").slice(0, 19) : "-";
var tMax = times.length ? times[times.length - 1].replace("T", " ").slice(0, 19) : "-";
var durs = access.map(function (r) { return Number(r.durationMs) || 0; });
var avg = durs.length ? Math.round(durs.reduce(function (a, b) { return a + b; }, 0) / durs.length) : 0;
var maxD = durs.length ? Math.max.apply(null, durs) : 0;
var html = "";
/* 概览卡片 */
html += '<h2>概览</h2><div class="cards">'
+ card(rows.length, "日志条数")
+ card(access.length, "请求数(ACCESS)")
+ card(errors.length, "错误 ERROR", errors.length ? "bad" : "")
+ card(warns.length, "警告 WARN", warns.length ? "warn" : "")
+ card(slow.length, "慢请求 > " + lim + "ms", slow.length ? "warn" : "")
+ card(avg + " ms", "平均耗时")
+ card(maxD + " ms", "最大耗时")
+ "</div>"
+ '<div class="hint" style="margin-top:8px">时间范围:' + esc(tMin) + " ~ " + esc(tMax) + "</div>";
/* 错误 */
html += "<h2>错误(ERROR)</h2>";
if (!errors.length) {
html += '<div class="empty">没有 ERROR 级别日志。</div>';
} else {
var er = errors.slice().sort(function (a, b) { return (a["@timestamp"] || "").localeCompare(b["@timestamp"] || ""); });
var body = er.slice(0, topLimit()).map(function (r) {
var st = r.stack_trace ? '<details><summary>堆栈</summary><div class="st">' + esc(r.stack_trace) + "</div></details>" : "";
return [
'<span class="tag lvl-ERROR">ERROR</span>',
'<span class="mono">' + esc((r["@timestamp"] || "").replace("T", " ").slice(0, 19)) + "</span>",
esc(r.uri || r.method || ""),
esc(r.message || ""),
r.params ? '<span class="params">' + esc(r.params) + "</span>" : "",
r.traceId ? traceLink(r.traceId) : "",
st
];
});
html += '<div class="scroll">' + tableHTML(["级别", "时间", "接口", "消息", "参数", "traceId", "详情"], body) + "</div>";
if (er.length > topLimit()) html += '<div class="hint">仅显示前 ' + topLimit() + " 条,共 " + er.length + " 条。</div>";
}
/* 慢请求 */
html += "<h2>慢请求(耗时 &gt; " + lim + " ms)</h2>";
if (!slow.length) {
html += '<div class="empty">没有超过阈值的请求。</div>';
} else {
var sbody = slow.slice(0, topLimit()).map(function (r) {
return [
'<span class="mono">' + esc((r["@timestamp"] || "").replace("T", " ").slice(0, 19)) + "</span>",
esc(r.method || ""),
esc(r.uri || ""),
'<span class="num">' + esc(r.durationMs) + "</span>",
esc(r.orgId || "anonymous"),
esc(r.userId || ""),
r.params ? '<span class="params">' + esc(r.params) + "</span>" : "",
r.traceId ? traceLink(r.traceId) : "",
esc(r.clientIp || "")
];
});
html += '<div class="scroll">' + tableHTML(["时间", "方法", "接口", "耗时(ms)", "机构", "用户", "参数", "traceId", "客户端IP"], sbody) + "</div>";
if (slow.length > topLimit()) html += '<div class="hint">仅显示最慢的 ' + topLimit() + " 条,共 " + slow.length + " 条。</div>";
}
/* 按机构 */
html += "<h2>按机构统计(请求数)</h2>";
var orgs = groupCount(access, function (r) { return r.orgId || "anonymous"; });
html += '<div class="scroll">' + tableHTML(["机构", "请求数"], orgs.slice(0, topLimit() + 20).map(function (e) {
return [esc(e[0]), '<span class="num">' + e[1] + "</span>"];
})) + "</div>";
/* 按接口 */
html += "<h2>按接口统计(请求数 / 最大耗时)</h2>";
var uriMap = new Map();
access.forEach(function (r) {
var k = r.uri || "(空)";
var o = uriMap.get(k) || { n: 0, max: 0, sum: 0 };
o.n++; var d = Number(r.durationMs) || 0; o.max = Math.max(o.max, d); o.sum += d;
uriMap.set(k, o);
});
var uriRows = Array.from(uriMap.entries()).sort(function (a, b) { return b[1].max - a[1].max; });
html += '<div class="scroll">' + tableHTML(["接口", "请求数", "最大耗时(ms)", "平均耗时(ms)"], uriRows.slice(0, topLimit() + 20).map(function (e) {
return [esc(e[0]), '<span class="num">' + e[1].n + "</span>", '<span class="num">' + e[1].max + "</span>", '<span class="num">' + Math.round(e[1].sum / e[1].n) + "</span>"];
})) + "</div>";
/* 状态码 */
html += "<h2>状态码分布</h2>";
var st = groupCount(access, function (r) { return r.status; });
html += '<div class="scroll">' + tableHTML(["状态码", "次数"], st.map(function (e) {
return [esc(e[0]), '<span class="num">' + e[1] + "</span>"];
})) + "</div>";
/* 按小时 */
html += "<h2>按小时统计</h2>";
var hMap = new Map();
rows.forEach(function (r) {
var ts = r["@timestamp"] || "";
var hh = ts.slice(0, 13);
if (!hh) return;
var o = hMap.get(hh) || { total: 0, err: 0 };
o.total++;
if (r.level === "ERROR") o.err++;
hMap.set(hh, o);
});
var hRows = Array.from(hMap.entries()).sort(function (a, b) { return a[0].localeCompare(b[0]); });
html += '<div class="scroll">' + tableHTML(["小时", "日志数", "其中 ERROR"], hRows.map(function (e) {
return [esc(e[0].replace("T", " ")), '<span class="num">' + e[1].total + "</span>", '<span class="num">' + e[1].err + "</span>"];
})) + "</div>";
/* traceId 查询 */
html += '<h2>按 traceId 追踪</h2><div class="controls" style="margin-bottom:8px">'
+ '<input type="text" id="traceInput" placeholder="输入 traceId 回车,或点击上方链接" value="' + esc(traceQuery) + '">'
+ '<button class="btn" id="btnTrace">查询</button>'
+ '<button class="btn" id="btnTraceClear">清除</button></div>';
html += '<div id="traceOut">' + renderTrace() + "</div>";
out.innerHTML = html;
/* 绑定事件 */
document.querySelectorAll("[data-trace]").forEach(function (el) {
el.addEventListener("click", function () {
traceQuery = el.getAttribute("data-trace");
window.scrollTo({ top: 0, behavior: "smooth" });
render();
var inp = $("traceInput"); if (inp) inp.focus();
});
});
var ti = $("traceInput");
if (ti) {
ti.addEventListener("keydown", function (e) { if (e.key === "Enter") { traceQuery = ti.value.trim(); render(); } });
}
var bt = $("btnTrace");
if (bt) bt.addEventListener("click", function () { traceQuery = ($("traceInput").value || "").trim(); render(); });
var bc = $("btnTraceClear");
if (bc) bc.addEventListener("click", function () { traceQuery = ""; render(); });
}
function card(n, t, cls) {
return '<div class="card ' + (cls || "") + '"><div class="n">' + esc(n) + '</div><div class="t">' + esc(t) + "</div></div>";
}
function renderTrace() {
if (!traceQuery) return '<div class="empty">输入 traceId 后,会列出该次请求的全链路日志(访问行 + 业务日志 + 异常)。</div>';
var hit = rows.filter(function (r) { return r.traceId === traceQuery; })
.sort(function (a, b) { return (a["@timestamp"] || "").localeCompare(b["@timestamp"] || ""); });
if (!hit.length) return '<div class="empty">没有找到 traceId = ' + esc(traceQuery) + " 的日志。</div>";
var body = hit.map(function (r) {
var lvl = r.level || "";
var detail = r.stack_trace ? '<details><summary>堆栈</summary><div class="st">' + esc(r.stack_trace) + "</div></details>" : "";
var extra = [];
if (r.method || r.uri) extra.push((r.method || "") + " " + (r.uri || ""));
if (r.status !== undefined) extra.push("status=" + r.status);
if (r.durationMs !== undefined) extra.push("耗时=" + r.durationMs + "ms");
if (r.orgId) extra.push("机构=" + r.orgId);
if (r.userId) extra.push("用户=" + r.userId);
if (r.clientIp) extra.push("IP=" + r.clientIp);
if (r.params) extra.push("参数=" + r.params);
return [
'<span class="tag lvl-' + esc(lvl) + '">' + esc(lvl) + "</span>",
'<span class="mono">' + esc((r["@timestamp"] || "").replace("T", " ").slice(0, 23)) + "</span>",
esc(r.logger_name || ""),
esc(r.message || ""),
esc(extra.join(",")) + detail
];
});
return '<div class="hint">共 ' + hit.length + " 条</div><div class=\"scroll\">"
+ tableHTML(["级别", "时间", "来源", "消息", "上下文"], body) + "</div>";
}
/* ---------- 事件绑定 ---------- */
$("btnFiles").addEventListener("click", function () { $("pickFiles").click(); });
$("btnDir").addEventListener("click", function () { $("pickDir").click(); });
$("pickFiles").addEventListener("change", function (e) { if (e.target.files.length) load(e.target.files); });
$("pickDir").addEventListener("change", function (e) { if (e.target.files.length) load(e.target.files); });
$("slowMs").addEventListener("change", render);
$("topN").addEventListener("change", render);
var drop = $("drop");
["dragenter", "dragover"].forEach(function (ev) {
drop.addEventListener(ev, function (e) { e.preventDefault(); drop.classList.add("on"); });
});
["dragleave", "drop"].forEach(function (ev) {
drop.addEventListener(ev, function (e) { e.preventDefault(); drop.classList.remove("on"); });
});
drop.addEventListener("drop", async function (e) {
var files = await collectDropped(e.dataTransfer);
if (files.length) load(files);
});
/* 支持拖入文件夹(递归收集) */
async function collectDropped(dt) {
var out = [];
var entries = [];
if (dt.items) {
for (var i = 0; i < dt.items.length; i++) {
var en = dt.items[i].webkitGetAsEntry && dt.items[i].webkitGetAsEntry();
if (en) entries.push(en);
}
}
if (!entries.length) return Array.prototype.slice.call(dt.files);
for (var j = 0; j < entries.length; j++) await walkEntry(entries[j], out);
return out;
}
function walkEntry(entry, out) {
return new Promise(function (resolve) {
if (entry.isFile) {
entry.file(function (f) { out.push(f); resolve(); }, function () { resolve(); });
} else if (entry.isDirectory) {
var reader = entry.createReader();
var all = [];
(function readBatch() {
reader.readEntries(async function (ents) {
if (!ents.length) {
for (var i = 0; i < all.length; i++) await walkEntry(all[i], out);
resolve();
return;
}
all.push.apply(all, ents);
readBatch();
}, function () { resolve(); });
})();
} else {
resolve();
}
});
}
/* 启动时给出提示 */
$("status").textContent = "等待选择日志…";
render();
</script>
</body>
</html>