@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,152 @@
1
+ // ===== node-dashboard 大屏 · 分服务器(instances.js)=====
2
+ // 单台实例卡片渲染(renderCard),由 common.js 的 loadInner 调用,写入 #grid。
3
+ // 依赖 common.js 提供的 $ / ring / bar / sparkline / fmtBytes / fmtUptime / pctColor /
4
+ // stateColor / qpmCell / qpmSection / qpmSnapshotBlock / channelTypeLabel / SPARK_COLORS / sparkPoints 等全局符号。
5
+
6
+ function renderCard(inst, data, err, isSelf, idx) {
7
+ const cls = (err ? "card err" : "card") + (isSelf ? " self" : "");
8
+ let body;
9
+ if (err) {
10
+ body = `<div class="cbody full"><div class="kv"><span class="k">错误</span><span class="v">${err}</span></div></div>`;
11
+ } else {
12
+ const h = data.host, svc = data.service;
13
+ const ENV_LABEL = { prod: "生产", test: "测试", dev: "开发" };
14
+ const envRaw = inst.env || data?.service?.env || "—";
15
+ const envLabel = ENV_LABEL[envRaw] || envRaw;
16
+ const envClass = ENV_LABEL[envRaw] ? envRaw : "unknown";
17
+ const disk = (h.disk?.mounts || []).map((m) => {
18
+ const p = Number(m.usagePercent) || 0;
19
+ return `<div class="disk">
20
+ <div class="disk-head"><span>磁盘 ${m.mount}</span><span>${p.toFixed(1)}%</span></div>
21
+ <div class="track"><div class="fill" style="width:${Math.min(100, p)}%;background:${pctColor(p)}"></div></div>
22
+ <div class="disk-meta">总 ${fmtBytes(m.total)} · 已用 ${fmtBytes(m.used)} · 剩 ${fmtBytes(m.free)}</div>
23
+ </div>`;
24
+ }).join("");
25
+ // —— 组件健康(模块为主):shardorm 分组 + node-lock/node-log/http/breaker + 单 Redis 根因行 ——
26
+ const cache = data.cache || {};
27
+ const info = data.shardormInfo || {};
28
+ const comps = data.components || {};
29
+ const stCls = (s) => s || "unknown";
30
+ const bbadge = (text, cls) => `<span class="backend-badge ${cls}">${text}</span>`;
31
+ const modRow = (name, status, attrs) =>
32
+ `<div class="comp"><span class="name">${name}</span><span class="dot ${stCls(status)}"></span><span class="mod-attrs">${attrs || ""}</span></div>`;
33
+
34
+ // shardorm 分组:缓存/指标后端 + 各实例连接/同步/恢复/连接池
35
+ const instRows = (info.instances || []).map((inst) => {
36
+ const conn = inst.connection;
37
+ const cStatus = conn ? conn.status : (inst.configured ? "unknown" : "skipped");
38
+ const nodes = (conn && conn.nodes) || [];
39
+ const down = nodes.filter((n) => n.status === "down").length;
40
+ const sync = inst.sync || {};
41
+ const rec = inst.recover || {};
42
+ const pools = inst.pool || [];
43
+ const poolStr = pools.length ? pools.map((p) => `${p.active}/${p.limit}`).join(" ") : "—";
44
+ const syncStr = sync.mode ? `${sync.mode}·差异${sync.structureIssues ?? 0}` : "—";
45
+ const recStr = `总数${rec.total ?? 0}·恢复${rec.recovered ?? 0}${Array.isArray(rec.dangling) && rec.dangling.length ? "·悬挂" + rec.dangling.length : ""}`;
46
+ const connStr = nodes.length ? `${nodes.length}节点${down ? " " + down + "异常" : "正常"}` : "—";
47
+ return `<div class="comp sub"><span class="name">${inst.name}</span><span class="dot ${stCls(cStatus)}"></span><span class="state">连接 ${connStr} | 同步 ${syncStr} | 恢复 ${recStr} | 池 ${poolStr}</span></div>`;
48
+ }).join("");
49
+ const shardStatuses = (info.instances || []).map((i) => (i.connection ? i.connection.status : "unknown"));
50
+ const shardormStatus = shardStatuses.includes("down") ? "down"
51
+ : shardStatuses.includes("degraded") ? "degraded"
52
+ : shardStatuses.length ? "ok" : "unknown";
53
+ const shardormAttrs =
54
+ bbadge("缓存:" + (cache.shardormCache === "off" ? "关闭" : cache.shardormCache === "redis" ? "Redis" : "内存"), cache.shardormCache === "off" ? "off" : cache.shardormCache === "redis" ? "redis" : "memory") +
55
+ bbadge("指标:" + (cache.shardormMetrics === "redis" ? "Redis" : "内存"), cache.shardormMetrics === "redis" ? "redis" : "memory");
56
+ const shardormGroup = `<div class="mod-group">
57
+ <div class="comp"><span class="name">shardorm</span><span class="dot ${stCls(shardormStatus)}"></span><span class="mod-attrs">${shardormAttrs}</span></div>
58
+ ${instRows}
59
+ </div>`;
60
+
61
+ // 其它模块 + 单 Redis 根因行
62
+ const redisComp = comps.redis || {};
63
+ const redisStatus = redisComp.status || "unknown";
64
+ const lockAttrs = bbadge("锁后端:" + (cache.logLock === "redis" ? "Redis" : "无锁直写"), cache.logLock === "redis" ? "redis" : "none");
65
+ const logAttrs = bbadge("缓冲:" + (cache.logBuffer || "内存"), "memory") + bbadge("写锁:" + (cache.logLock === "redis" ? "Redis" : "无锁直写"), cache.logLock === "redis" ? "redis" : "none");
66
+ const httpStatus = data.http ? "ok" : "unknown";
67
+ const otherMods = [
68
+ modRow("node-lock", cache.logLock === "redis" ? redisStatus : "skipped", lockAttrs),
69
+ modRow("node-log", "ok", logAttrs),
70
+ modRow("http-client", httpStatus, ""),
71
+ modRow("breaker", (comps.breaker || {}).status || "unknown", ""),
72
+ ].join("") + (comps.externalApi ? modRow("外部API", (comps.externalApi || {}).status || "unknown", "") : "");
73
+ const redisRoot = modRow("Redis (根因)", redisStatus,
74
+ bbadge(redisStatus === "skipped" ? "未配置" : "连通", redisStatus === "skipped" ? "none" : "redis") +
75
+ (redisComp.latencyMs != null ? `<span class="state">${redisComp.latencyMs}ms</span>` : ""));
76
+ const modsHtml = `<div class="comps mods"><div class="comp-head">组件健康</div>${shardormGroup}${otherMods}${redisRoot}</div>`;
77
+ // 推送中心:node-push 是否加载、累计推送/成功率(来自 data.push.push overview)、按通道明细
78
+ const push = data.push || {};
79
+ const pushLoaded = !!push.loaded;
80
+ const pushStatus = !pushLoaded ? "unknown" : (push.error ? "down" : "ok");
81
+ const pushOv = push.push || {};
82
+ const pushByCh = pushOv.byChannel || [];
83
+ const pushChannels = pushByCh.map((c) => {
84
+ const lbl = c.typeLabel || channelTypeLabel(c.type);
85
+ return `<div class="comp sub"><span class="name">${lbl}</span><span class="dot ok"></span><span class="state">${c.count} 次 · 成功 ${c.successRate}</span></div>`;
86
+ }).join("");
87
+ const pushStorage = push.backends?.storage?.backend || "—";
88
+ const pushHtml = `<div class="mod-group">
89
+ <div class="comp"><span class="name">推送中心</span><span class="dot ${pushStatus}"></span><span class="mod-attrs">${bbadge("node-push:" + (pushLoaded ? "已加载" : "未加载"), pushLoaded ? "redis" : "none")}${bbadge("存储:" + pushStorage, "memory")}${(pushOv.successRate ? bbadge("成功率:" + pushOv.successRate, "ok") : "")}${push.error ? bbadge("异常:" + push.error, "off") : ""}</span></div>
90
+ <div class="comp sub"><span class="name">累计推送</span><span class="state">${(pushOv.totalTasks ?? 0)} 次 · 失败 ${(pushOv.failedTasks ?? 0)}</span></div>
91
+ ${pushChannels}
92
+ </div>`;
93
+ body = `<div class="cbody">
94
+ <div class="ctop">
95
+ <div>
96
+ <div class="kv">
97
+ <div class="item"><span class="k">主机</span><span class="v">${h.hostname} (${h.ips?.join(", ") || "—"})</span></div>
98
+ <div class="item"><span class="k">系统</span><span class="v">${h.os?.type} ${h.os?.release} · ${h.os?.arch}</span></div>
99
+ <div class="item"><span class="k">Node</span><span class="v">${h.nodeVersion}</span></div>
100
+ <div class="item"><span class="k">端口</span><span class="v">${inst.port}</span></div>
101
+ <div class="item"><span class="k">PID</span><span class="v">${svc.pid}</span></div>
102
+ <div class="item"><span class="k">环境</span><span class="v"><span class="env-badge ${envClass}">${envLabel}</span></span></div>
103
+ <div class="item"><span class="k">启动</span><span class="v">${new Date(svc.startedAt).toLocaleString()}</span></div>
104
+ <div class="item"><span class="k">运行时长</span><span class="v">${fmtUptime(svc.uptimeMs)}</span></div>
105
+ <div class="item"><span class="k">事件环延迟</span><span class="v">${h.process?.eventLoopLagMs ?? "?"} ms</span></div>
106
+ </div>
107
+ <div class="rings">
108
+ <div class="ring-wrap">
109
+ ${ring(h.cpu?.usage, pctColor(h.cpu?.usage))}
110
+ <div class="ring-label">CPU</div>
111
+ <div class="ring-sub">${h.cpu?.model || "—"} · ${h.cpu?.cores}核<br>load ${h.cpu?.loadavg?.map((x) => x.toFixed(2)).join("/") || "-"}</div>
112
+ </div>
113
+ <div class="ring-wrap">
114
+ ${ring(h.memory?.usagePercent, pctColor(h.memory?.usagePercent))}
115
+ <div class="ring-label">内存</div>
116
+ <div class="ring-sub">${fmtBytes(h.memory?.used)} / ${fmtBytes(h.memory?.total)}<br>RSS ${fmtBytes(h.process?.rss)}</div>
117
+ </div>
118
+ </div>
119
+ ${disk ? `<div class="bars">${disk}</div>` : ""}
120
+ </div>
121
+ <div>
122
+ ${modsHtml}${pushHtml}
123
+ </div>
124
+ </div>
125
+ <div class="cstats">
126
+ ${apiChannelCardsHtml("i" + idx)}
127
+ ${channelPairHtml("i" + idx, "shardorm", "分库分表 · shardorm", "SQL 次数 QPM", "SQL 耗时 ms")}
128
+ ${channelPairHtml("i" + idx, "http", "出口 HTTP · http-client", "HTTP 次数 QPM", "HTTP 耗时 ms")}
129
+ ${(() => {
130
+ // 推送 QPM:数据在 data.push.qpm(PushQpmSnapshot),与 apirouter/shardorm 的 QpmSnapshot 字段不同(无 total/ok/fail/avgMs)
131
+ const pq = data.push?.qpm;
132
+ const has = !!pq;
133
+ const v = (x) => (has ? (x ?? 0) : "—");
134
+ return qpmSection("推送中心 · node-push",
135
+ qpmCell("当前QPM", v(pq?.qpmCurrent)) +
136
+ qpmCell("近1分均值", v(pq?.qpmAvg)) +
137
+ qpmCell("通道种类", has ? (pq?.byChannel?.length ?? 0) : "—") +
138
+ qpmCell("主题数", has ? (pq?.byTopic?.length ?? 0) : "—"),
139
+ `<div class="spark-chart" data-idx="${idx}" data-kind="push"></div>`);
140
+ })()}
141
+ </div>
142
+ </div>
143
+ </div>`;
144
+ }
145
+ const env = inst.env || (data?.service?.env) || "—";
146
+ return `<div class="${cls}">
147
+ <div class="chead">
148
+ <span class="dot ${err ? "down" : "ok"}"></span>
149
+ ${isSelf ? '<span class="self-badge">本机</span>' : ""}<span class="title">${inst.ip}:${inst.port}</span>
150
+ <span class="env">${env} · ${inst.instanceId || ""}</span>
151
+ </div>${body}</div>`;
152
+ }
@@ -0,0 +1,178 @@
1
+ // ===== node-dashboard 大屏 · 推送测试弹窗(push.js)=====
2
+ // 自包含 IIFE:node-push 测试发送弹窗的逻辑(通道配置、服务器选择、发送)。
3
+ // 仅依赖 DOM(#pushModal / #pushTestBtn 等)与 window.__pushInstances(由 common.js 的 loadInner 写入)。
4
+
5
+ (function () {
6
+ const byId = (id) => document.getElementById(id);
7
+ const modal = byId("pushModal");
8
+ const openBtn = byId("pushTestBtn");
9
+ if (!modal || !openBtn) return;
10
+ const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
11
+
12
+ // 各通道默认配置(从 /_sys/push/defaults 拉取,含 pushdeer / 邮件 等),用于弹窗自动预填
13
+ let PUSH_DEFAULTS = {};
14
+ async function loadPushDefaults() {
15
+ try {
16
+ // 该端点返回真实凭证,服务端要求有效 access 令牌:沿用弹窗里的「access token」输入框
17
+ const token = (byId("ptToken") && byId("ptToken").value || "").trim();
18
+ const headers = token ? { Authorization: "Bearer " + token } : {};
19
+ const r = await fetch("/_sys/push/defaults", { method: "GET", headers });
20
+ const d = await r.json();
21
+ if (d && d.ok && d.defaults) PUSH_DEFAULTS = d.defaults;
22
+ } catch (e) {
23
+ /* 非关键,预填失败不影响手动输入 */
24
+ }
25
+ }
26
+
27
+ // 每种通道一套字段:fields=表单字段,to=接收人提示(空则隐藏该输入),build=组装 extra
28
+ const CH_CFG = {
29
+ webhook: { label: "Webhook", fields: [
30
+ { k: "url", label: "Webhook URL", ph: "https://example.com/hook" },
31
+ { k: "allowPrivate", label: "允许内网/回环地址(SSRF 防护,测本机时勾选)", type: "checkbox" },
32
+ ], build: (v) => ({ url: v.url, allowPrivate: !!v.allowPrivate }) },
33
+ email: { label: "邮件", fields: [
34
+ { k: "smtpHost", label: "SMTP 主机" },
35
+ { k: "smtpPort", label: "SMTP 端口(可选)", ph: "465" },
36
+ { k: "username", label: "SMTP 账号" },
37
+ { k: "password", label: "SMTP 密码", type: "password" },
38
+ { k: "from", label: "发件人(可选)" },
39
+ ], to: "收件人邮箱,如 a@b.com", build: (v) => { const e = { smtpHost: v.smtpHost, username: v.username, password: v.password }; if (v.smtpPort) e.smtpPort = Number(v.smtpPort); if (v.from) e.from = v.from; return e; } },
40
+ mqtt: { label: "MQTT", fields: [ { k: "broker", label: "MQTT Broker", ph: "tcp://127.0.0.1:1883" } ], to: "MQTT topic,如 test/push", build: (v) => ({ broker: v.broker }) },
41
+ // 钉钉安全设置三选一/多选:自定义关键词(keyword) / IP地址段(服务端出口 IP,无需字段) / 加签(secret)
42
+ dingtalk: { label: "钉钉", fields: [
43
+ { k: "webhook", label: "钉钉机器人 Webhook" },
44
+ { k: "secret", label: "加签密钥(安全设置勾选「加签」时填)", type: "password" },
45
+ { k: "keyword", label: "自定义关键词(安全设置勾选「自定义关键词」时填)", ph: "如 告警" },
46
+ ], build: (v) => { const e = { webhook: v.webhook }; if (v.secret) e.secret = v.secret; if (v.keyword) e.keyword = v.keyword; return e; } },
47
+ wecom: { label: "企业微信", fields: [ { k: "webhook", label: "企微机器人 Webhook" } ], build: (v) => ({ webhook: v.webhook }) },
48
+ // 飞书安全设置三选一/多选:自定义关键词(secret 之下的 keyword) / IP白名单(服务端出口 IP,无需字段) / 签名校验(secret)
49
+ feishu: { label: "飞书", fields: [
50
+ { k: "webhook", label: "飞书机器人 Webhook" },
51
+ { k: "secret", label: "签名校验密钥(安全设置勾选「签名校验」时填)", type: "password" },
52
+ { k: "keyword", label: "自定义关键词(安全设置勾选「自定义关键词」时填)", ph: "如 告警" },
53
+ ], build: (v) => { const e = { webhook: v.webhook }; if (v.secret) e.secret = v.secret; if (v.keyword) e.keyword = v.keyword; return e; } },
54
+ pushdeer: { label: "PushDeer", fields: [ { k: "pushkey", label: "接收人key(PushDeer pushkey)" }, { k: "server", label: "PushDeer 服务器(可选)" } ], build: (v) => { const e = { pushkey: v.pushkey }; if (v.server) e.server = v.server; return e; } },
55
+ console: { label: "控制台(兜底,无需配置)", fields: [], build: () => ({}) },
56
+ ntfy: { label: "ntfy", fields: [ { k: "server", label: "ntfy 服务器(可选)", ph: "https://ntfy.sh" }, { k: "priority", label: "优先级 1-5(可选)" } ], to: "ntfy topic,如 mypush", build: (v) => { const e = {}; if (v.server) e.server = v.server; if (v.priority) e.priority = Number(v.priority); return e; } },
57
+ };
58
+
59
+ function renderChannelOptions() {
60
+ const sel = byId("ptChannel");
61
+ sel.innerHTML = Object.keys(CH_CFG).map((t) => `<option value="${t}">${esc(CH_CFG[t].label)}(${t})</option>`).join("");
62
+ sel.value = "console"; // 默认控制台,零外部依赖必定可测
63
+ }
64
+ function renderCfg() {
65
+ const type = byId("ptChannel").value || "console";
66
+ const spec = CH_CFG[type] || CH_CFG.console;
67
+ byId("ptChanCfg").innerHTML = (spec.fields || []).map((f) => f.type === "checkbox"
68
+ ? `<label style="display:flex;align-items:center;gap:8px;margin-top:10px"><input type="checkbox" id="cfg-${f.k}" style="width:auto" /> ${esc(f.label)}</label>`
69
+ : `<label>${esc(f.label)}</label><input id="cfg-${f.k}" placeholder="${esc(f.ph || "")}"${f.type === "password" ? ' type="password"' : ""} />`
70
+ ).join("");
71
+ // 从 /_sys/push/defaults 自动预填(按字段 k 对齐):pushdeer 的 pushkey、邮件的 SMTP 等
72
+ const dv = PUSH_DEFAULTS[type] || {};
73
+ for (const f of spec.fields || []) {
74
+ const el = byId("cfg-" + f.k);
75
+ if (!el || dv[f.k] === undefined) continue;
76
+ if (f.type === "checkbox") el.checked = !!dv[f.k];
77
+ else el.value = dv[f.k];
78
+ }
79
+ const toWrapOk = !!spec.to;
80
+ byId("ptTo").style.display = toWrapOk ? "" : "none";
81
+ byId("ptToLabel").style.display = toWrapOk ? "" : "none";
82
+ if (toWrapOk) byId("ptTo").placeholder = spec.to;
83
+ }
84
+ function readCfg(type) {
85
+ const spec = CH_CFG[type] || CH_CFG.console;
86
+ const v = {};
87
+ for (const f of spec.fields || []) {
88
+ const el = byId("cfg-" + f.k);
89
+ v[f.k] = f.type === "checkbox" ? !!(el && el.checked) : (el ? el.value.trim() : "");
90
+ }
91
+ return spec.build(v);
92
+ }
93
+ function renderServers() {
94
+ const sel = byId("ptServer");
95
+ const list = Array.isArray(window.__pushInstances) ? window.__pushInstances : [];
96
+ if (!list.length) {
97
+ sel.innerHTML = `<option value="-1">${esc(location.host)} · 本机</option>`;
98
+ return;
99
+ }
100
+ sel.innerHTML = list.map((it, i) => `<option value="${i}">${esc(it.ip + ":" + it.port)} · ${esc(it.env || "?")}${it._self ? " · 本机" : ""}</option>`).join("");
101
+ let selfIdx = list.findIndex((it) => it._self || (it.ip === location.hostname && Number(it.port) === Number(location.port || 3000)));
102
+ if (selfIdx < 0) selfIdx = 0;
103
+ sel.value = String(selfIdx);
104
+ }
105
+
106
+ const close = () => modal.classList.remove("open");
107
+ openBtn.addEventListener("click", () => {
108
+ renderServers();
109
+ modal.classList.add("open");
110
+ byId("ptResult").textContent = "";
111
+ loadPushDefaults().then(() => renderCfg()); // 打开即拉取默认配置并预填
112
+ });
113
+ byId("ptClose").addEventListener("click", close);
114
+ modal.addEventListener("click", (e) => { if (e.target === modal) close(); });
115
+ byId("ptChannel").addEventListener("change", renderCfg);
116
+ byId("ptModeSeg").addEventListener("click", (e) => {
117
+ const btn = e.target.closest("button[data-mode]");
118
+ if (!btn) return;
119
+ const mode = btn.getAttribute("data-mode");
120
+ byId("ptMode").value = mode;
121
+ byId("ptModeSeg").querySelectorAll("button").forEach((b) => b.classList.toggle("active", b === btn));
122
+ renderModeCfg();
123
+ });
124
+ function renderModeCfg() {
125
+ const showInterval = byId("ptMode").value === "throttle";
126
+ byId("ptInterval").style.display = showInterval ? "" : "none";
127
+ byId("ptIntervalLabel").style.display = showInterval ? "" : "none";
128
+ }
129
+ loadPushDefaults(); // 预取默认配置(打开弹窗时已缓存)
130
+ renderChannelOptions();
131
+ renderCfg();
132
+ renderModeCfg();
133
+
134
+ byId("ptSend").addEventListener("click", async () => {
135
+ const btn = byId("ptSend");
136
+ const out = byId("ptResult");
137
+ const list = Array.isArray(window.__pushInstances) ? window.__pushInstances : [];
138
+ const sel = byId("ptServer");
139
+ const inst = sel.value !== "-1" ? list[Number(sel.value)] : null;
140
+ const base = !inst || inst._self ? "" : `http://${inst.ip}:${inst.port}`;
141
+ const type = byId("ptChannel").value || "console";
142
+ const topic = byId("ptTopic").value.trim();
143
+ const title = byId("ptTitle").value.trim() || "大屏测试推送";
144
+ const count = Math.max(1, Number(byId("ptCount").value) || 1);
145
+ const token = byId("ptToken").value.trim();
146
+ const to = byId("ptTo").value.split(/[,,]/).map((s) => s.trim()).filter(Boolean);
147
+ const mode = byId("ptMode").value;
148
+ const throttleIntervalMs = Number(byId("ptInterval").value) || 60000;
149
+ const extra = readCfg(type);
150
+ btn.disabled = true;
151
+ out.textContent = "发送中…";
152
+ const headers = { "Content-Type": "application/json" };
153
+ if (token) headers["Authorization"] = "Bearer " + token;
154
+ let ok = 0, sent = 0, lastErr = "";
155
+ try {
156
+ const r = await fetch(`${base}/_sys/push/test`, {
157
+ method: "POST",
158
+ headers,
159
+ body: JSON.stringify({ topic, title, channelType: type, channel: { extra }, to, count, mode, throttleIntervalMs }),
160
+ });
161
+ const txt = await r.text();
162
+ let data = null;
163
+ try { data = JSON.parse(txt); } catch { /* 非 JSON */ }
164
+ if (r.ok && data && data.ok) {
165
+ ok = 1;
166
+ sent = data.sent ?? 0;
167
+ } else {
168
+ lastErr = data?.error || `HTTP ${r.status} ${txt.slice(0, 200)}`;
169
+ }
170
+ } catch (e) {
171
+ lastErr = String(e && e.message ? e.message : e);
172
+ }
173
+ btn.disabled = false;
174
+ const where = inst ? `${inst.ip}:${inst.port}` : location.host;
175
+ out.textContent = `已发送 ${sent}/${count} 条 → ${where} · 通道 ${type}\n` + (lastErr ? `错误:${lastErr}\n` : "") + (ok ? (mode === "throttle" && sent === 0 ? "(已被节流缓冲,到点后由定时器真实投递;控制台通道会在目标机控制台打印)" : "(控制台通道会在目标机控制台打印;稍后自动刷新可见)") : "");
176
+ if (ok && typeof load === "function") load();
177
+ });
178
+ })();
package/src/config.ts ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * 大屏配置注入点(替代原 node-server 的 config.env / config.obs)。
3
+ *
4
+ * 包不读环境变量,由宿主启动时注入:env(用于 Redis qpm 历史键的分桶)、obsRequireAuth(/_sys 是否鉴权)。
5
+ */
6
+
7
+ let env = "dev";
8
+ let obsRequireAuth = false;
9
+ let systemName = "服务";
10
+
11
+ export function configureDashboard(opts: { env?: "prod" | "test" | "dev"; obsRequireAuth?: boolean; systemName?: string }): void {
12
+ if (opts.env) env = opts.env;
13
+ if (opts.obsRequireAuth !== undefined) obsRequireAuth = opts.obsRequireAuth;
14
+ if (opts.systemName) systemName = opts.systemName;
15
+ }
16
+
17
+ export const dashboardConfig = {
18
+ get env() {
19
+ return env;
20
+ },
21
+ get obsRequireAuth() {
22
+ return obsRequireAuth;
23
+ },
24
+ get systemName() {
25
+ return systemName;
26
+ },
27
+ };
@@ -0,0 +1,171 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { dashboardConfig } from "./config.js";
6
+ import { histGetAgg, histGetCmd, mergeMemHist } from "./qpm-hist.js";
7
+
8
+ /**
9
+ * 服务器监控大屏:聚合 API + 前端托管。
10
+ *
11
+ * 包内提供:
12
+ * - `mountDashboard(app, opts)`:在 Fastify 上挂大屏静态页(由宿主 www 根提供,本包只暴露数据接口)与 `/_sys/_agg`(聚合快照);
13
+ * - `writeDashboardAssets(dir)`:把 index.html 落到宿主的 www 目录(启动时统一静态根,允许覆盖);
14
+ * - `registerDashboardSources(opts)`:各服务注册「大屏展示哪些数据源」;
15
+ * - `registerAggProvider(fn)`:宿主注入实时内存指标(apirouter QosStats / http-client Metrics),与 Redis 历史合并。
16
+ *
17
+ * 聚合端点(/_sys/_agg)默认公开;若宿主配置 `obsRequireAuth=true`,则授权由宿主的鉴权中间件兜住。
18
+ * 注意:聚合路由放在 `/_sys/_agg` 而非 `/api/_agg`,避免与 apirouter 的 `/api/:module` 冲突。
19
+ */
20
+
21
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
22
+
23
+ let sources: Record<string, unknown> = {};
24
+ let aggProvider: (() => { qpm?: Record<string, { mem: Array<{ t: number; count: number }> }>; health?: unknown } | null) | null =
25
+ null;
26
+
27
+ /** 各服务注册大屏数据源开关(如 { shardorm: true, sqlTrace: false, cards: [...] }) */
28
+ export function registerDashboardSources(opts: Record<string, unknown>): void {
29
+ sources = { ...sources, ...opts };
30
+ }
31
+
32
+ /** 宿主注入实时内存指标采集器(与 Redis 历史合并;不注入则只返回 sources 与 Redis 历史) */
33
+ export function registerAggProvider(
34
+ fn: () => { qpm?: Record<string, { mem: Array<{ t: number; count: number }> }>; health?: unknown } | null,
35
+ ): void {
36
+ aggProvider = fn;
37
+ }
38
+
39
+ function instanceId(): string {
40
+ const ip = Object.values(os.networkInterfaces())
41
+ .flat()
42
+ .find((i: any) => i && i.family === "IPv4" && !i.internal)?.address ?? "127.0.0.1";
43
+ return `${ip}:${Number(process.env.PORT ?? 3000)}`;
44
+ }
45
+
46
+ /** 生成聚合快照(/_agg 返回体) */
47
+ export async function buildAgg(): Promise<Record<string, unknown>> {
48
+ const provider = aggProvider ? safeCall(aggProvider) : null;
49
+ const qpm: Record<string, unknown> = {};
50
+
51
+ // Redis 历史(无 Redis / 读失败则 null)
52
+ const svcNames = new Set<string>(Object.keys(provider?.qpm ?? {}));
53
+ for (const svc of svcNames) {
54
+ const mem = (provider?.qpm?.[svc]?.mem as Array<{ t: number; count: number }>) ?? [];
55
+ const hist = await histGetAgg(svc);
56
+ qpm[svc] = {
57
+ redis: hist,
58
+ // 折线序列:Redis 历史(含 avg/max/min 耗时)优先,内存当前分钟补尾
59
+ perMinute: hist
60
+ ? mergeMemHist(mem, hist.perMinute.map((b) => ({ t: b.t, count: b.count }))).map((b) => {
61
+ const h = hist.perMinute.find((x) => x.t === b.t);
62
+ return { t: b.t, count: b.count, fail: h?.fail ?? 0, avgMs: h?.avgMs ?? 0, maxMs: h?.maxMs ?? 0, minMs: h?.minMs ?? 0 };
63
+ })
64
+ : mem,
65
+ };
66
+ }
67
+
68
+ return {
69
+ instance: instanceId(),
70
+ env: dashboardConfig.env,
71
+ ts: Date.now(),
72
+ sources,
73
+ qpm,
74
+ health: provider?.health ?? null,
75
+ };
76
+ }
77
+
78
+ /** 单 cmd 钻取:聚合某 svc 下某 cmd 的逐分钟时序(Redis 历史,重启不丢)。 */
79
+ export async function buildCmdAgg(svc: string, cmd: string): Promise<Record<string, unknown> | null> {
80
+ const hist = await histGetCmd(svc, cmd);
81
+ if (!hist) return null;
82
+ return {
83
+ svc,
84
+ cmd,
85
+ total: hist.total,
86
+ ok: hist.ok,
87
+ fail: hist.fail,
88
+ avgMs: hist.avgMs,
89
+ maxMs: hist.maxMs,
90
+ minMs: hist.minMs,
91
+ perMinute: hist.perMinute,
92
+ };
93
+ }
94
+
95
+ function safeCall<T>(fn: () => T): T | null {
96
+ try {
97
+ return fn();
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ function readAssetHtml(): string {
104
+ try {
105
+ return fs.readFileSync(path.join(__dirname, "assets", "index.html"), "utf8");
106
+ } catch {
107
+ return "<!doctype html><meta charset=utf-8><title>dashboard</title><h1>大屏资源缺失</h1>";
108
+ }
109
+ }
110
+
111
+ /** 把模板里的 __SYSTEM_NAME__ 占位符替换为配置的系统名称(默认「服务」) */
112
+ function renderHtml(raw: string): string {
113
+ const name = dashboardConfig.systemName || "服务";
114
+ return raw.replace(/__SYSTEM_NAME__/g, name);
115
+ }
116
+
117
+ export interface MountDashboardOptions {
118
+ /** 聚合 API 路由(默认 /_sys/_agg) */
119
+ aggRoute?: string;
120
+ }
121
+
122
+ /**
123
+ * 在 Fastify 实例上挂载大屏数据接口。
124
+ * 大屏静态页由宿主把 www/index.html 作为静态根提供(首页即大屏),本函数只挂数据 API。
125
+ * 宿主需在静态/JSON 路由上自行处理鉴权(obsRequireAuth=true 时)。
126
+ */
127
+ export function mountDashboard(app: any, opts: MountDashboardOptions = {}): void {
128
+ const aggRoute = opts.aggRoute ?? "/_sys/_agg";
129
+ if (app && typeof app.get === "function") {
130
+ app.get(aggRoute, async (_req: any, reply: any) => {
131
+ reply.header("content-type", "application/json; charset=utf-8");
132
+ return buildAgg();
133
+ });
134
+ app.get(`${aggRoute}/cmd`, async (req: any, reply: any) => {
135
+ reply.header("content-type", "application/json; charset=utf-8");
136
+ const svc = String(req.query?.svc ?? "");
137
+ const cmd = String(req.query?.cmd ?? "");
138
+ if (!svc || !cmd) return reply.code(400).send({ error: "svc and cmd required" });
139
+ const data = await buildCmdAgg(svc, cmd);
140
+ if (!data) return reply.code(404).send({ error: "no data" });
141
+ return data;
142
+ });
143
+ }
144
+ }
145
+
146
+ /** 递归拷贝目录(用于把包内 assets 整目录落到宿主 www) */
147
+ function copyDir(src: string, dest: string): void {
148
+ fs.mkdirSync(dest, { recursive: true });
149
+ for (const ent of fs.readdirSync(src, { withFileTypes: true })) {
150
+ const s = path.join(src, ent.name);
151
+ const d = path.join(dest, ent.name);
152
+ if (ent.isDirectory()) copyDir(s, d);
153
+ else fs.copyFileSync(s, d);
154
+ }
155
+ }
156
+
157
+ /** 把大屏资源写入宿主 www 目录(统一静态根 / 允许覆盖)。
158
+ * 包内 assets 含:index.html(壳,含 __SYSTEM_NAME__ 占位)+ js/{common,agg,instances,push}.js。
159
+ * 整目录拷贝后,再用「系统名称」渲染 index.html 覆盖占位符(js 文件无需替换)。 */
160
+ export function writeDashboardAssets(dir: string): void {
161
+ try {
162
+ fs.mkdirSync(dir, { recursive: true });
163
+ // 整目录拷贝:index.html + js/*.js 一并落到 www,保证脚本相对路径(js/common.js)可用
164
+ copyDir(path.join(__dirname, "assets"), dir);
165
+ // 每次启动都用「包内壳 + 系统名称」重新渲染,覆盖宿主 www/index.html,
166
+ // 保证各系统(代理商/商户/支付/会员/收银)显示各自的系统名称,且 UI 与包同步。
167
+ fs.writeFileSync(path.join(dir, "index.html"), renderHtml(readAssetHtml()));
168
+ } catch (e) {
169
+ console.warn("[dashboard] 写入大屏资源失败:", (e as Error).message);
170
+ }
171
+ }
@@ -0,0 +1,65 @@
1
+ import { GlobalLogger } from "@yixinshuke/node-log";
2
+ import type { Logger, LogFields } from "@yixinshuke/node-http-client";
3
+
4
+ /** 从 url 字段提取 host(按 host 聚合到同一日志文件);解析失败回退 "http" */
5
+ function hostOf(url?: unknown): string {
6
+ if (typeof url !== "string") return "http";
7
+ try {
8
+ return new URL(url).host || "http";
9
+ } catch {
10
+ return "http";
11
+ }
12
+ }
13
+
14
+ /**
15
+ * node-http-client → node-log 日志桥接。
16
+ *
17
+ * http-client 的 `Logger` 端口是「字段式」(info(fields)/error(fields)/warn/debug),
18
+ * 这里转投 node-log:
19
+ * - 全量请求字段(url / method / durationMs / status / kind / 错误信息 …)透传进 `extra`,便于 JSON 检索;
20
+ * - 按 host 聚合:落到 `node-server/http/<host>.log`(同一下游一台机器一天一个文件);
21
+ * - 观测是非关键路径:桥接抛错一律吞掉,**绝不阻断 http 主链路**(与 node-log 铁律一致)。
22
+ *
23
+ * 用法:把 createHttpClientLogger() 作为 callHttp 的 `logger` 选项传入即可;
24
+ * 设为 false 仍可在调用处完全关闭日志。
25
+ */
26
+ export function createHttpClientLogger(): Logger {
27
+ // 懒加载:不在创建时取 GlobalLogger(模块导入早于 index.ts 的 initLogger(),
28
+ // 此时取会抛「请先 init」)。首次真正请求时再取并缓存,届时 init 已完成。
29
+ let logger: GlobalLogger | null = null;
30
+
31
+ const emit = (level: "debug" | "info" | "warn" | "error", fields: LogFields): void => {
32
+ try {
33
+ if (!logger) logger = GlobalLogger.getInstance();
34
+ const host = hostOf(fields.url);
35
+ const msg =
36
+ typeof fields.msg === "string" && fields.msg ? fields.msg : JSON.stringify(fields);
37
+ const ctx = {
38
+ category: "node-server/http",
39
+ merchantId: 0,
40
+ storeId: 0,
41
+ system: "node-server",
42
+ bizKey: host,
43
+ extra: fields,
44
+ };
45
+ const fn = (
46
+ {
47
+ debug: logger.debug.bind(logger),
48
+ info: logger.info.bind(logger),
49
+ warn: logger.warn.bind(logger),
50
+ error: logger.error.bind(logger),
51
+ } as const
52
+ )[level];
53
+ fn(ctx, msg);
54
+ } catch {
55
+ /* 观测非关键路径:吞错,绝不阻断 http 主链路 */
56
+ }
57
+ };
58
+
59
+ return {
60
+ debug: (f) => emit("debug", f),
61
+ info: (f) => emit("info", f),
62
+ warn: (f) => emit("warn", f),
63
+ error: (f) => emit("error", f),
64
+ };
65
+ }
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ // 观测采集器
2
+ export * from "./sql-trace.js";
3
+ export * from "./qpm-hist.js";
4
+ export * from "./shardorm-logger.js";
5
+ export * from "./http-logger.js";
6
+ export * from "./panel-token-inject.js";
7
+ // 大屏框架
8
+ export * from "./dashboard.js";
9
+ // 配置注入
10
+ export { configureDashboard, dashboardConfig } from "./config.js";