This commit is contained in:
oneao committed 2026-10-13 17:29:55 +08:00
1 parent 7bd5f1dde9
commit f3936cec61
70 files changed
+13074 -187

No files matched your search

+414
View File
@@ -0,0 +1,414 @@
<!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>