@yixinshuke/node-dashboard 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,594 @@
1
+ // ===== node-dashboard 大屏 · 公共层(common.js)=====
2
+ // 全局状态 / 工具函数 / 图表与折线机件 / 聚合计算 / 刷新编排 / tab 切换。
3
+ // 本文件须最先加载;agg.js / instances.js / push.js 在其后加载,
4
+ // 最后由 index.html 末尾的内联启动脚本调用 load() 与 startTimer()。
5
+
6
+ const params = new URLSearchParams(location.search);
7
+ let ENV = params.get("env") || "all";
8
+ let TAB = "agg";
9
+ let W = Math.min(1440, Math.max(1, Number(params.get("w")) || 10)); // 折线时间窗口(分钟)
10
+ let timer = null;
11
+ const REFRESH = 5;
12
+
13
+ const $ = (id) => document.getElementById(id);
14
+ const fmtBytes = (n) => {
15
+ if (!n && n !== 0) return "—";
16
+ const u = ["B", "KB", "MB", "GB", "TB"];
17
+ let i = 0; let v = n;
18
+ while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; }
19
+ return `${v.toFixed(1)} ${u[i]}`;
20
+ };
21
+ const fmtUptime = (ms) => {
22
+ const s = Math.floor(ms / 1000);
23
+ const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
24
+ return `${d > 0 ? d + "天" : ""}${h}时${m}分${sec}秒`;
25
+ };
26
+ const pctColor = (p) => (p >= 85 ? "#f87272" : p >= 60 ? "#fbbd23" : "#36d399");
27
+ const stateColor = (s) => ({ ok: "var(--ok)", degraded: "var(--warn)", down: "var(--down)", unknown: "var(--unknown)", skipped: "var(--unknown)" }[s] || "var(--unknown)");
28
+
29
+ function bar(label, pct) {
30
+ const p = Number(pct) || 0;
31
+ return `<div class="bar"><div class="lab"><span>${label}</span><span>${p.toFixed(1)}%</span></div>
32
+ <div class="track"><div class="fill" style="width:${Math.min(100, p)}%;background:${pctColor(p)}"></div></div></div>`;
33
+ }
34
+
35
+ function ring(pct, color) {
36
+ const p = Math.max(0, Math.min(100, Number(pct) || 0));
37
+ const r = 42, c = 2 * Math.PI * r;
38
+ const off = c * (1 - p / 100);
39
+ return `<svg viewBox="0 0 100 100" class="ring">
40
+ <circle class="track" cx="50" cy="50" r="${r}"></circle>
41
+ <circle class="prog" cx="50" cy="50" r="${r}" stroke="${color}" stroke-dasharray="${c.toFixed(2)}" stroke-dashoffset="${off.toFixed(2)}"></circle>
42
+ <text x="50" y="50" class="ring-pct">${p.toFixed(0)}%</text>
43
+ </svg>`;
44
+ }
45
+
46
+ function sparkline(arr, color) {
47
+ if (!Array.isArray(arr) || arr.length === 0) return "";
48
+ const max = Math.max(1, ...arr);
49
+ const w = 100, h = 36, n = arr.length;
50
+ const pts = arr.map((v, i) => `${(i / (n - 1 || 1)) * w},${h - (v / max) * (h - 4) - 2}`).join(" ");
51
+ return `<svg viewBox="0 0 ${w} ${h}" preserveAspectRatio="none">
52
+ <polyline points="${pts}" fill="none" stroke="${color}" stroke-width="2" />
53
+ </svg>`;
54
+ }
55
+
56
+ const SPARK_COLORS = { apirouter: "#5b9dff", shardorm: "#36d399", http: "#fbbd23", push: "#f0883e" };
57
+ const fmtHM = (t) => {
58
+ const d = new Date(t);
59
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
60
+ };
61
+ /** ECharts 折线:x 轴时间标签、y 轴数值、悬停显示节点数据;echarts 不可用时降级为无轴 SVG */
62
+ function renderSpark(el, recent, color) {
63
+ if (!el) return;
64
+ if (!window.echarts) {
65
+ el.innerHTML = sparkline(recent.map((b) => b.count), color);
66
+ return;
67
+ }
68
+ const chart = echarts.getInstanceByDom(el) || echarts.init(el);
69
+ chart.setOption({
70
+ animation: false,
71
+ grid: { left: 34, right: 8, top: 10, bottom: 20 },
72
+ tooltip: {
73
+ trigger: "axis",
74
+ backgroundColor: "#1a2030",
75
+ borderColor: "#2c3550",
76
+ textStyle: { color: "#e6ebf5", fontSize: 12 },
77
+ formatter: (ps) => {
78
+ const p = ps[0];
79
+ return `${p.axisValue}<br/>${p.marker}QPM ${p.data}`;
80
+ },
81
+ },
82
+ xAxis: {
83
+ type: "category",
84
+ data: recent.map((b) => fmtHM(b.t)),
85
+ axisLabel: { color: "#8b97b3", fontSize: 10, interval: "auto" },
86
+ axisLine: { lineStyle: { color: "#2c3550" } },
87
+ axisTick: { show: false },
88
+ },
89
+ yAxis: {
90
+ type: "value",
91
+ minInterval: 1,
92
+ axisLabel: { color: "#8b97b3", fontSize: 10 },
93
+ splitLine: { lineStyle: { color: "#2c3550", opacity: 0.5 } },
94
+ },
95
+ series: [{
96
+ type: "line",
97
+ data: recent.map((b) => b.count),
98
+ smooth: true,
99
+ symbol: "circle",
100
+ symbolSize: 5,
101
+ lineStyle: { color, width: 2 },
102
+ itemStyle: { color },
103
+ areaStyle: { color, opacity: 0.12 },
104
+ }],
105
+ }, true);
106
+ }
107
+ /** 挂载一个容器内的所有 .spark-chart(按 data-idx / data-kind 取数据) */
108
+ function mountSparks(root, resolve) {
109
+ root.querySelectorAll(".spark-chart").forEach((el) => {
110
+ const { pts, color } = resolve(el.dataset);
111
+ renderSpark(el, pts, color);
112
+ });
113
+ }
114
+ /** 由实例状态块得到归一化折线数据(含当前进行中分钟点) */
115
+ function sparkPoints(blk) {
116
+ const nowMin = Math.floor(Date.now() / 60000) * 60000;
117
+ return mergeRecent([normalizeRecent(blk || {}, nowMin)], nowMin);
118
+ }
119
+
120
+ function qpmCell(lab, val) {
121
+ return `<div class="cell"><span class="lab">${lab}</span><span class="val">${val}</span></div>`;
122
+ }
123
+ function qpmSection(head, cells, extra = "") {
124
+ return `<div class="router">
125
+ <div class="router-head">${head} <span class="tag">QPM</span></div>
126
+ <div class="router-grid">${cells}</div>${extra}
127
+ </div>`;
128
+ }
129
+ const CHANNEL_TYPE_LABELS = { 1: "Webhook", 2: "邮件", 3: "MQTT", 4: "钉钉", 5: "企业微信", 6: "飞书", 7: "PushDeer", 8: "控制台兜底" };
130
+ function channelTypeLabel(t) { return CHANNEL_TYPE_LABELS[t] || ("类型" + t); }
131
+
132
+ /** 归一各源的逐分钟序列({t:分钟起始ms,count})。apirouter 的 qpmRecent 只含「已完成分钟」,
133
+ * 进行中分钟的计数在 qpmCurrent 里:序列缺当前分钟点时补上(已有则不补,防 http/shardorm 双计)。 */
134
+ function normalizeRecent(blk, nowMin) {
135
+ let arr = blk.recent || (blk.qpmRecent || []).map((b) => ({ t: b.t ?? b.minuteStartMs, count: b.count }));
136
+ if (!arr.some((b) => b.t === nowMin) && blk.qpmCurrent) {
137
+ arr = arr.concat({ t: nowMin, count: blk.qpmCurrent });
138
+ }
139
+ return arr;
140
+ }
141
+ /** 跨实例合并逐分钟序列,补齐最近 W 分钟、时间升序(最后一格 = 进行中的当前分钟) */
142
+ function mergeRecent(lists, nowMin) {
143
+ const map = new Map();
144
+ for (const l of lists) {
145
+ for (const b of (l || [])) map.set(b.t, (map.get(b.t) || 0) + (b.count || 0));
146
+ }
147
+ const out = [];
148
+ for (let i = W - 1; i >= 0; i--) {
149
+ const t = nowMin - i * 60000;
150
+ out.push({ t, count: map.get(t) || 0 });
151
+ }
152
+ return out;
153
+ }
154
+ function computeAgg(instances, results) {
155
+ const byService = {
156
+ apirouter: { qpm: 0, ok: 0, fail: 0, avgW: 0, avgSum: 0 },
157
+ shardorm: { qpm: 0, ok: 0, fail: 0, avgW: 0, avgSum: 0 },
158
+ http: { qpm: 0, ok: 0, fail: 0, avgW: 0, avgSum: 0 },
159
+ push: { qpm: 0, totalTasks: 0, successTasks: 0, failedTasks: 0 },
160
+ };
161
+ const recents = { apirouter: [], shardorm: [], http: [], push: [] };
162
+ const nowMin = Math.floor(Date.now() / 60000) * 60000;
163
+ for (const r of results) {
164
+ const d = r.data;
165
+ if (!d) continue;
166
+ const add = (blk, key) => {
167
+ if (!blk) return;
168
+ byService[key].qpm += blk.qpmCurrent ?? 0;
169
+ byService[key].ok += blk.ok ?? 0;
170
+ byService[key].fail += blk.fail ?? 0;
171
+ // 平均耗时跨实例按请求数加权(各源的 avgMs 均为与其 ok/fail 同窗口的平均)
172
+ const w = (blk.ok ?? 0) + (blk.fail ?? 0);
173
+ byService[key].avgW += w;
174
+ byService[key].avgSum += (blk.avgMs ?? 0) * w;
175
+ recents[key].push(normalizeRecent(blk, nowMin));
176
+ };
177
+ add(d.router, "apirouter");
178
+ add(d.shardorm, "shardorm");
179
+ add(d.http, "http");
180
+ // node-push 聚合:QPM 当前值求和 + 折线合并;overview 的累计推送/成功失败跨实例累加
181
+ const pq = d.push?.qpm;
182
+ if (pq) {
183
+ byService.push.qpm += pq.qpmCurrent ?? 0;
184
+ recents.push.push(normalizeRecent(pq, nowMin));
185
+ }
186
+ const ov = d.push?.push;
187
+ if (ov) {
188
+ byService.push.totalTasks += ov.totalTasks ?? 0;
189
+ byService.push.successTasks += ov.successTasks ?? 0;
190
+ byService.push.failedTasks += ov.failedTasks ?? 0;
191
+ }
192
+ }
193
+ for (const k of Object.keys(byService)) {
194
+ byService[k].recent = mergeRecent(recents[k], nowMin);
195
+ byService[k].avg = byService[k].avgW ? Math.round(byService[k].avgSum / byService[k].avgW) : 0;
196
+ }
197
+ return { env: ENV, serverCount: instances.length, byService };
198
+ }
199
+
200
+ let selfStatus = null; // 自身状态(pid/hostname/env 进程生命周期内不变,只取一次,避免每次刷新重复请求)
201
+ let loadBusy = false;
202
+ async function load() {
203
+ if (loadBusy) return; // 防重入:自动刷新与手动刷新不叠加
204
+ loadBusy = true;
205
+ try {
206
+ await loadInner();
207
+ } finally {
208
+ loadBusy = false;
209
+ }
210
+ }
211
+ async function loadInner() {
212
+ $("updated").textContent = "更新于 " + new Date().toLocaleTimeString();
213
+ // 自身标识:用 pid+hostname 唯一确定一个进程,用于在大屏中凸显「本机」卡片(仅首次请求)
214
+ if (!selfStatus) {
215
+ try {
216
+ selfStatus = await fetch("/_sys/status").then((r) => r.json());
217
+ } catch { selfStatus = null; }
218
+ }
219
+ const self = selfStatus ? { pid: selfStatus.service?.pid, hostname: selfStatus.host?.hostname } : null;
220
+ let instances = [];
221
+ try {
222
+ const r = await fetch(`/_sys/instances?env=${ENV}`);
223
+ instances = await r.json();
224
+ } catch (e) { instances = []; }
225
+ if (!Array.isArray(instances) || instances.length === 0) {
226
+ // 无 Redis 注册时回退:展示本实例(复用 selfStatus,不再重复请求)
227
+ instances = selfStatus
228
+ ? [{ ip: location.hostname, port: Number(location.port) || 3000, host: selfStatus.host?.hostname, env: selfStatus.service?.env, instanceId: "local", _self: true }]
229
+ : [];
230
+ }
231
+ // 暴露实例列表:供「推送测试」弹窗做服务器选择(分服务器测试)
232
+ window.__pushInstances = instances;
233
+ // 浏览器直拉各实例 /_sys/status(CORS):一份数据同时供「分服务器」卡片与「聚合总览」使用
234
+ const results = await Promise.all(instances.map(async (inst) => {
235
+ const base = inst._self ? "" : `http://${inst.ip}:${inst.port}`;
236
+ const url = base + (inst.statusPath || "/_sys/status");
237
+ try {
238
+ const data = await fetch(`${url}?w=${W}`).then((r) => r.json());
239
+ return { inst, data, err: null };
240
+ } catch (e) {
241
+ return { inst, data: null, err: String(e && e.message ? e.message : e) };
242
+ }
243
+ }));
244
+ const cards = results.map((r, i) => {
245
+ const isSelf = !!(r.data && self && r.data?.service?.pid === self.pid && r.data?.host?.hostname === self.hostname);
246
+ return renderCard(r.inst, r.data, r.err, isSelf, i);
247
+ });
248
+ const grid = $("grid");
249
+ grid.innerHTML = cards.join("");
250
+ // 仅当 node-push 已加载(任一实例 push.loaded)时,显示「推送测试」按钮
251
+ const pushReady = results.some((r) => r.data?.push?.loaded);
252
+ const ptBtn = document.getElementById("pushTestBtn");
253
+ if (ptBtn) ptBtn.classList.toggle("hidden", !pushReady);
254
+ mountSparks(grid, (ds) => {
255
+ if (ds.kind === "push") {
256
+ // 推送折线数据在 data.push.qpm.qpmRecent,不在 data.push 顶层
257
+ const pq = results[Number(ds.idx)]?.data?.push?.qpm;
258
+ return { pts: pq ? sparkPoints(pq) : [], color: SPARK_COLORS.push || "#f0883e" };
259
+ }
260
+ const r = results[Number(ds.idx)] || {};
261
+ return { pts: sparkPoints(r.data ? r.data[ds.kind] : null), color: SPARK_COLORS[ds.kind] || "#5b9dff" };
262
+ });
263
+ // 分服务器:每台卡片的 apirouter 3 通道 ×(次数/时间)= 6 卡片(按单实例)
264
+ await Promise.all(results.map(async (r, i) => {
265
+ for (const ns of API_CHANNELS) {
266
+ const cv = await buildChannelView([r], ns);
267
+ fillChannelStats("i" + i, ns, cv.stats);
268
+ renderQpmChart($("qpm-i" + i + "-" + ns), cv.qpm);
269
+ renderLatChart($("lat-i" + i + "-" + ns), cv.lat);
270
+ }
271
+ // 分库分表 shardorm:SQL 次数 + SQL 耗时 2 卡(按单实例)
272
+ const sd = await buildShardormView([r]);
273
+ fillChannelStats("i" + i, "shardorm", sd.stats);
274
+ renderQpmChart($("qpm-i" + i + "-shardorm"), sd.qpm);
275
+ renderLatChart($("lat-i" + i + "-shardorm"), sd.lat);
276
+ // 出口 HTTP:HTTP 次数 + HTTP 耗时 2 卡(按单实例)
277
+ const hv = await buildHttpView([r]);
278
+ fillChannelStats("i" + i, "http", hv.stats);
279
+ renderQpmChart($("qpm-i" + i + "-http"), hv.qpm);
280
+ renderLatChart($("lat-i" + i + "-http"), hv.lat);
281
+ }));
282
+ const ok = results.filter((r) => !r.err).length;
283
+ $("kpiOk").textContent = ok;
284
+ const badEl = $("kpiBad");
285
+ badEl.textContent = instances.length - ok;
286
+ badEl.style.color = instances.length - ok > 0 ? "var(--down)" : "var(--ok)";
287
+ const agg = computeAgg(instances, results);
288
+ renderKpi(agg);
289
+ renderAgg(agg);
290
+ // 分库分表 shardorm:SQL 次数 + SQL 耗时 2 卡(聚合跨实例合并)
291
+ const sdAgg = await buildShardormView(results);
292
+ fillChannelStats("agg", "shardorm", sdAgg.stats);
293
+ renderQpmChart($("qpm-agg-shardorm"), sdAgg.qpm);
294
+ renderLatChart($("lat-agg-shardorm"), sdAgg.lat);
295
+ // 出口 HTTP:HTTP 次数 + HTTP 耗时 2 卡(聚合跨实例合并)
296
+ const hvAgg = await buildHttpView(results);
297
+ fillChannelStats("agg", "http", hvAgg.stats);
298
+ renderQpmChart($("qpm-agg-http"), hvAgg.qpm);
299
+ renderLatChart($("lat-agg-http"), hvAgg.lat);
300
+ // apirouter 3 通道 ×(次数/时间)= 6 卡片:聚合总览(跨实例合并)
301
+ const apiCh = await buildAllChannels(results);
302
+ for (const ns of API_CHANNELS) {
303
+ fillChannelStats("agg", ns, apiCh[ns].stats);
304
+ renderQpmChart($("qpm-agg-" + ns), apiCh[ns].qpm);
305
+ renderLatChart($("lat-agg-" + ns), apiCh[ns].lat);
306
+ }
307
+ }
308
+
309
+ function startTimer() {
310
+ clearInterval(timer);
311
+ if ($("auto").checked) timer = setInterval(load, REFRESH * 1000);
312
+ }
313
+
314
+ $("filters").addEventListener("click", (e) => {
315
+ const b = e.target.closest("button");
316
+ if (!b) return;
317
+ ENV = b.dataset.env;
318
+ [...$("filters").children].forEach((x) => x.classList.toggle("active", x === b));
319
+ const u = new URL(location); u.searchParams.set("env", ENV); history.replaceState(null, "", u);
320
+ load();
321
+ });
322
+ // 折线时间窗口切换(10分钟 / 1小时 / 24小时)
323
+ $("winCtl").addEventListener("click", (e) => {
324
+ const b = e.target.closest("button");
325
+ if (!b) return;
326
+ W = Number(b.dataset.w) || 10;
327
+ [...$("winCtl").children].forEach((x) => x.classList.toggle("active", x === b));
328
+ const u = new URL(location); u.searchParams.set("w", String(W)); history.replaceState(null, "", u);
329
+ load();
330
+ });
331
+ $("auto").addEventListener("change", startTimer);
332
+ // 手动刷新:按钮进入 loading 态(旋转 + 禁用 + 文案变化),并保证效果至少停留 500ms
333
+ let refreshing = false;
334
+ async function refreshWithFx() {
335
+ const btn = $("refreshNow");
336
+ if (refreshing) return;
337
+ refreshing = true;
338
+ const label = btn.textContent;
339
+ btn.textContent = "刷新中…";
340
+ btn.disabled = true;
341
+ btn.classList.add("loading");
342
+ try {
343
+ await Promise.all([load(), new Promise((r) => setTimeout(r, 500))]);
344
+ } finally {
345
+ btn.textContent = label;
346
+ btn.disabled = false;
347
+ btn.classList.remove("loading");
348
+ refreshing = false;
349
+ }
350
+ }
351
+ $("refreshNow").addEventListener("click", refreshWithFx);
352
+ function resizeSparks(root) {
353
+ if (!window.echarts) return;
354
+ root.querySelectorAll(".spark-chart, .api-chart").forEach((el) => {
355
+ const c = echarts.getInstanceByDom(el);
356
+ if (c) c.resize();
357
+ });
358
+ }
359
+ function applyTab() {
360
+ [...$("tabs").children].forEach((x) => x.classList.toggle("active", x.dataset.tab === TAB));
361
+ $("aggView").classList.toggle("hidden", TAB !== "agg");
362
+ $("grid").classList.toggle("hidden", TAB !== "instances");
363
+ // 容器从 display:none 变可见后,隐藏期间 init 的 ECharts 按 0 宽渲染,需 resize 重算
364
+ const root = TAB === "agg" ? $("aggView") : $("grid");
365
+ requestAnimationFrame(() => resizeSparks(root));
366
+ }
367
+ // 浏览器窗口缩放时所有图表重算尺寸
368
+ window.addEventListener("resize", () => resizeSparks(document.body));
369
+ $("tabs").addEventListener("click", (e) => {
370
+ const b = e.target.closest("button");
371
+ if (!b) return;
372
+ TAB = b.dataset.tab;
373
+ applyTab();
374
+ });
375
+ applyTab();
376
+
377
+ // 顶部服务名(取自本实例状态)
378
+ fetch("/_sys/status").then((r) => r.json()).then((d) => {
379
+ $("serviceName").textContent = `${d.service?.name || "service"} v${d.service?.version || "?"} · ${d.service?.env || "?"}`;
380
+ }).catch(() => {});
381
+
382
+ // ===== apirouter 通道(api/open/callback)次数/时间 卡片 =====
383
+ // 次数卡:QPM(次数 + 错误次数),数据来自各实例 /_sys/status 的 channels[ns].qpmRecent(已随 results 加载,无需额外请求)
384
+ // 时间卡:耗时(平均/最大/P90/P99),数据来自各实例 /_sys/qos/channel?svc=apirouter-<ns>(含直方图,跨实例合并)
385
+ const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
386
+ /** 直方图桶上界(ms),须与 node-dashboard/qpm-hist DEFAULT_LAT_BOUNDS 一致(接口 bounds 字段为权威值) */
387
+ const BDEF = [1, 2, 3, 5, 8, 12, 20, 30, 50, 80, 120, 200, 300, 500, 1000, 2000, 5000, 10000];
388
+ function zeros(n) { return new Array(n).fill(0); }
389
+ /** 由直方图估算百分位:hist 桶计数数组,bounds 桶上界,maxMs 用于最后一桶兜底 */
390
+ function histPct(hist, bounds, p, maxMs) {
391
+ if (!hist || !hist.length) return 0;
392
+ let total = 0; for (const x of hist) total += x;
393
+ if (!total) return 0;
394
+ const target = p * total;
395
+ let cum = 0;
396
+ for (let i = 0; i < hist.length; i++) {
397
+ cum += hist[i];
398
+ if (cum >= target) {
399
+ const lower = i === 0 ? 0 : bounds[i - 1];
400
+ const upper = i >= bounds.length ? (maxMs ?? bounds[bounds.length - 1]) : bounds[i];
401
+ const prev = cum - hist[i];
402
+ const frac = hist[i] ? (target - prev) / hist[i] : 0;
403
+ let v = lower + (upper - lower) * frac;
404
+ if (!isFinite(v)) v = maxMs ?? bounds[bounds.length - 1];
405
+ return Math.round(v);
406
+ }
407
+ }
408
+ return Math.round(maxMs ?? bounds[bounds.length - 1]);
409
+ }
410
+ function fillLat(map, nowMin, B) {
411
+ const out = [];
412
+ for (let i = W - 1; i >= 0; i--) {
413
+ const t = nowMin - i * 60000;
414
+ const e = map.get(t);
415
+ if (!e) { out.push({ t, avgMs: 0, maxMs: 0, minMs: 0, p90: 0, p99: 0 }); continue; }
416
+ const avgMs = e.c ? Math.round(e.ls / e.c) : 0;
417
+ out.push({ t, avgMs, maxMs: e.lmx, minMs: e.lmn, p90: histPct(e.hist, B, 0.90, e.lmx), p99: histPct(e.hist, B, 0.99, e.lmx) });
418
+ }
419
+ return out;
420
+ }
421
+ function renderLine(el, xData, seriesList) {
422
+ if (!el || !window.echarts) return;
423
+ const chart = echarts.getInstanceByDom(el) || echarts.init(el);
424
+ chart.setOption({
425
+ animation: false,
426
+ grid: { left: 50, right: 14, top: 28, bottom: 22 },
427
+ legend: { textStyle: { color: "#8b97b3", fontSize: 11 }, top: 0, itemWidth: 14, itemGap: 8 },
428
+ tooltip: { trigger: "axis", backgroundColor: "#1a2030", borderColor: "#2c3550", textStyle: { color: "#e6ebf5", fontSize: 12 } },
429
+ xAxis: { type: "category", data: xData, axisLabel: { color: "#8b97b3", fontSize: 10, formatter: (v) => v.slice(0, 5) }, axisLine: { lineStyle: { color: "#2c3550" } }, axisTick: { show: false } },
430
+ yAxis: { type: "value", axisLabel: { color: "#8b97b3", fontSize: 10 }, splitLine: { lineStyle: { color: "#2c3550", opacity: 0.5 } } },
431
+ series: seriesList.map((s) => ({ name: s.name, type: "line", data: s.data, smooth: true, symbol: "none", lineStyle: { color: s.color, width: 2 }, itemStyle: { color: s.color }, areaStyle: s.area ? { color: s.color, opacity: 0.12 } : undefined })),
432
+ }, true);
433
+ }
434
+ // ===== apirouter 通道(api/open/callback)次数/时间 卡片 =====
435
+ const API_CHANNELS = ["api", "open", "callback"];
436
+ const NS_LABEL = { api: "API (/api)", open: "开放接口 (/open)", callback: "回调 (/callback)" };
437
+
438
+ function fillRecentCount(map, nowMin) {
439
+ const out = [];
440
+ for (let i = W - 1; i >= 0; i--) { const t = nowMin - i * 60000; const e = map.get(t) || { count: 0, fail: 0 }; out.push({ t, count: e.count, fail: e.fail }); }
441
+ return out;
442
+ }
443
+
444
+ // 一对「次数 + 耗时」卡片(上=统计数字,下=折线图;图表容器用 .api-chart,避免被 mountSparks 误挂载;
445
+ // 数字单元格 id=st-<scope>-<key>-<k>,由 fillChannelStats 填充)
446
+ function channelPairHtml(scope, key, label, qpmTag = "次数 QPM", latTag = "耗时 ms") {
447
+ const cell = (k, text) => `<div class="cell"><span class="lab">${text}</span><span class="val" id="st-${scope}-${key}-${k}">—</span></div>`;
448
+ return `<div class="router">
449
+ <div class="router-head"><span class="dot ok"></span>${label} <span class="tag">${qpmTag}</span></div>
450
+ <div class="router-grid">
451
+ ${cell("total", "总请求 (24h)")}
452
+ ${cell("ok", "成功")}
453
+ ${cell("fail", "失败")}
454
+ ${cell("rate", "成功率")}
455
+ </div>
456
+ <div class="api-chart" id="qpm-${scope}-${key}"></div>
457
+ </div>
458
+ <div class="router">
459
+ <div class="router-head"><span class="dot ok"></span>${label} <span class="tag">${latTag}</span></div>
460
+ <div class="router-grid">
461
+ ${cell("avgMs", "平均耗时")}
462
+ ${cell("maxMs", "最大耗时")}
463
+ ${cell("minMs", "最小耗时")}
464
+ </div>
465
+ <div class="api-chart" id="lat-${scope}-${key}"></div>
466
+ </div>`;
467
+ }
468
+
469
+ // apirouter 3 通道 ×(次数/时间)= 6 张卡片
470
+ function apiChannelCardsHtml(scope) {
471
+ let h = "";
472
+ for (const ns of API_CHANNELS) h += channelPairHtml(scope, ns, NS_LABEL[ns] || ns);
473
+ return h;
474
+ }
475
+
476
+ // 把通道累计统计填进卡片数字区(聚合/单台共通)
477
+ function fillChannelStats(scope, ns, st) {
478
+ const set = (k, v) => { const el = $(`st-${scope}-${ns}-${k}`); if (el) el.textContent = v; };
479
+ if (!st) return;
480
+ set("total", (st.total ?? 0).toLocaleString());
481
+ set("ok", (st.ok ?? 0).toLocaleString());
482
+ set("fail", (st.fail ?? 0).toLocaleString());
483
+ set("rate", st.total ? ((st.ok / st.total) * 100).toFixed(1) + "%" : "—");
484
+ set("avgMs", (st.avgMs ?? 0) + "ms");
485
+ set("maxMs", (st.maxMs ?? 0) + "ms");
486
+ set("minMs", (st.minMs ?? 0) + "ms");
487
+ }
488
+
489
+ // qos/channel 404 退避缓存:ip:port:svc -> 允许重试的时间戳(端点对无历史数据的 svc 返回 404,退避避免自动刷新时 404 刷屏)
490
+ const qosNoData = new Map();
491
+
492
+ // 构建某通道(ns)在 results(多实例=聚合,单实例=分服务器)下的 统计 + QPM + 耗时。
493
+ // 次数/耗时同源:各实例 /_sys/qos/channel?svc=<svc>(24h 累计 + 逐分钟 count/fail + 直方图)。
494
+ // opts.svc:默认 apirouter-<ns>(shardorm 传 "shardorm");opts.fallback(r):qos 无数据时回退到该实例
495
+ // status 的 QpmSnapshot 块(如 shardorm 的 data.shardorm:累计统计 + qpmRecent 逐分钟次数)。
496
+ async function buildChannelView(results, ns, opts = {}) {
497
+ const nowMin = Math.floor(Date.now() / 60000) * 60000;
498
+ const svc = opts.svc ?? ("apirouter-" + ns);
499
+ const qmap = new Map();
500
+ const lmap = new Map();
501
+ const stats = { total: 0, ok: 0, fail: 0, ls: 0, maxMs: 0, minMs: 0 };
502
+ let B = BDEF;
503
+ await Promise.all(results.map(async (r) => {
504
+ const inst = r.inst; const base = inst._self ? "" : `http://${inst.ip}:${inst.port}`;
505
+ let got = false;
506
+ // 404 退避:端点对无历史数据的 svc 返回 404;记住后 2 分钟内不再请求,避免自动刷新时 404 刷屏
507
+ const qkey = `${inst.ip}:${inst.port}:${svc}`;
508
+ const skipQos = Date.now() < (qosNoData.get(qkey) || 0);
509
+ try {
510
+ const d = skipQos ? null : await fetch(`${base}/_sys/qos/channel?svc=${encodeURIComponent(svc)}`).then((x) => {
511
+ if (x.status === 404) { qosNoData.set(qkey, Date.now() + 120000); return null; }
512
+ return x.ok ? x.json() : null;
513
+ });
514
+ if (d && d.perMinute) {
515
+ got = true;
516
+ // 累计统计跨实例合并(avgMs 按请求数加权)
517
+ stats.total += d.total ?? 0; stats.ok += d.ok ?? 0; stats.fail += d.fail ?? 0;
518
+ stats.ls += (d.avgMs ?? 0) * (d.total ?? 0);
519
+ if ((d.maxMs ?? 0) > stats.maxMs) stats.maxMs = d.maxMs ?? 0;
520
+ if (d.minMs && (!stats.minMs || d.minMs < stats.minMs)) stats.minMs = d.minMs;
521
+ if (d.bounds && d.bounds.length) B = d.bounds;
522
+ const cb = (d.bounds && d.bounds.length ? d.bounds.length : BDEF.length) + 1;
523
+ for (const b of d.perMinute) {
524
+ // 逐分钟 次数/错误次数(次数卡折线)
525
+ const q = qmap.get(b.t) || { count: 0, fail: 0 };
526
+ q.count += b.count; q.fail += b.fail || 0; qmap.set(b.t, q);
527
+ // 逐分钟耗时(耗时卡折线)
528
+ const e = lmap.get(b.t) || { c: 0, ls: 0, lmx: 0, lmn: 0, hist: zeros(cb) };
529
+ e.c += b.count; e.ls += (b.avgMs || 0) * b.count;
530
+ if ((b.maxMs || 0) > e.lmx) e.lmx = b.maxMs || 0;
531
+ if (b.minMs && (!e.lmn || b.minMs < e.lmn)) e.lmn = b.minMs;
532
+ if (b.hist) for (let k = 0; k < cb && k < b.hist.length; k++) e.hist[k] += b.hist[k];
533
+ lmap.set(b.t, e);
534
+ }
535
+ }
536
+ } catch { /* ignore */ }
537
+ // qos/channel 无数据时回退到 status 快照(shardorm:RedisMetricsBackend 的累计 + qpmRecent)
538
+ if (!got && opts.fallback) {
539
+ const blk = opts.fallback(r);
540
+ if (blk) {
541
+ const total = blk.total ?? ((blk.ok ?? 0) + (blk.fail ?? 0));
542
+ stats.total += total; stats.ok += blk.ok ?? 0; stats.fail += blk.fail ?? 0;
543
+ stats.ls += (blk.avgMs ?? 0) * total;
544
+ if ((blk.maxMs ?? 0) > stats.maxMs) stats.maxMs = blk.maxMs ?? 0;
545
+ if (blk.minMs && (!stats.minMs || blk.minMs < stats.minMs)) stats.minMs = blk.minMs;
546
+ for (const b of (blk.qpmRecent || [])) {
547
+ const t = b.t ?? b.minuteStartMs;
548
+ const q = qmap.get(t) || { count: 0, fail: 0 };
549
+ q.count += b.count || 0; qmap.set(t, q);
550
+ }
551
+ }
552
+ }
553
+ }));
554
+ const qpm = fillRecentCount(qmap, nowMin);
555
+ const lat = fillLat(lmap, nowMin, B);
556
+ stats.avgMs = stats.total ? Math.round(stats.ls / stats.total) : 0;
557
+ return { stats, qpm, lat };
558
+ }
559
+
560
+ async function buildAllChannels(results) {
561
+ const out = {};
562
+ await Promise.all(API_CHANNELS.map(async (ns) => { out[ns] = await buildChannelView(results, ns); }));
563
+ return out;
564
+ }
565
+
566
+ // shardorm(SQL)视图:优先 /_sys/qos/channel?svc=shardorm,无则回退 status 的 data.shardorm 快照
567
+ async function buildShardormView(results) {
568
+ return buildChannelView(results, "shardorm", { svc: "shardorm", fallback: (r) => r.data?.shardorm });
569
+ }
570
+
571
+ // 出口 HTTP 视图:优先 /_sys/qos/channel?svc=http(含逐分钟耗时),无则回退 status 的 data.http 快照
572
+ async function buildHttpView(results) {
573
+ return buildChannelView(results, "http", { svc: "http", fallback: (r) => r.data?.http });
574
+ }
575
+
576
+ // 次数卡:次数 + 错误次数;时间卡:平均/最大/P90/P99
577
+ function renderQpmChart(el, pts) {
578
+ if (!el) return;
579
+ const xs = pts.map((p) => fmtHM(p.t));
580
+ renderLine(el, xs, [
581
+ { name: "次数", data: pts.map((p) => p.count), color: "#5b9dff", area: true },
582
+ { name: "错误次数", data: pts.map((p) => p.fail), color: "#f87272" },
583
+ ]);
584
+ }
585
+ function renderLatChart(el, pts) {
586
+ if (!el) return;
587
+ const xs = pts.map((p) => fmtHM(p.t));
588
+ renderLine(el, xs, [
589
+ { name: "平均", data: pts.map((p) => p.avgMs), color: "#5b9dff" },
590
+ { name: "最大", data: pts.map((p) => p.maxMs), color: "#f87272" },
591
+ { name: "P90", data: pts.map((p) => p.p90), color: "#fbbd23" },
592
+ { name: "P99", data: pts.map((p) => p.p99), color: "#a78bfa" },
593
+ ]);
594
+ }