citrine-devtools 0.1.0
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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +44 -0
- data/bin/citrine-devtools +27 -0
- data/docs/PROTOCOL.md +52 -0
- data/lib/citrine-devtools/bridge.js +264 -0
- data/lib/citrine-devtools/panel.html +127 -0
- data/lib/citrine-devtools/panel.js +1056 -0
- data/lib/citrine-devtools/server.rb +304 -0
- data/lib/citrine-devtools/vendor/cytoscape.min.js +32 -0
- data/lib/citrine-devtools/version.rb +7 -0
- data/lib/citrine-devtools.rb +6 -0
- metadata +113 -0
|
@@ -0,0 +1,1056 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* citrine-devtools 面板(M3 骨架 / M4 依赖图)——中继托管的独立 Web 面板,无构建 vanilla JS。
|
|
3
|
+
* 数据流(docs/PROTOCOL.md v1):
|
|
4
|
+
* 下行 EventSource /__devtools/stream 消费 tree / graph / signal_write;
|
|
5
|
+
* 上行 POST /__devtools/cmd 发 request_tree / request_graph。
|
|
6
|
+
*
|
|
7
|
+
* 结构(协作契约):
|
|
8
|
+
* 上半部是纯函数视图模型(不依赖 window/document,经 vm 沙箱加载本文件的
|
|
9
|
+
* module.exports 分支做单测);DOM/cytoscape 绑定只在浏览器分支执行。
|
|
10
|
+
*/
|
|
11
|
+
(function () {
|
|
12
|
+
"use strict";
|
|
13
|
+
|
|
14
|
+
var PROTOCOL_VERSION = 1;
|
|
15
|
+
var VALUE_PREVIEW_LIMIT = 40; // 信号值预览截断(write_log 已在探针侧截 200 字符)
|
|
16
|
+
var WRITE_LOG_LIMIT = 1000; // 面板侧写入流缓存上限(旧条目丢弃)
|
|
17
|
+
|
|
18
|
+
// ── 纯函数:消息校验(v 不符 / 非 JSON 一律忽略,协议口径)────────
|
|
19
|
+
|
|
20
|
+
function isValidMessage(message) {
|
|
21
|
+
return !!message && typeof message === "object" &&
|
|
22
|
+
message.v === PROTOCOL_VERSION && typeof message.type === "string";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// SSE data: 行 → 消息对象;解析失败或 v 不符返回 null
|
|
26
|
+
function parseSSEData(data) {
|
|
27
|
+
var message;
|
|
28
|
+
try {
|
|
29
|
+
message = JSON.parse(data);
|
|
30
|
+
} catch (err) {
|
|
31
|
+
return null; // 非 JSON 包一律忽略
|
|
32
|
+
}
|
|
33
|
+
return isValidMessage(message) ? message : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ── 纯函数:显示辅助 ─────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
// object_id 短号:末四位(信号表 sig 列、组件树 #node_id 共用)
|
|
39
|
+
function shortId(id) {
|
|
40
|
+
if (id === null || id === undefined) return "#----";
|
|
41
|
+
return "#" + String(id).slice(-4);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function pad2(n) { return n < 10 ? "0" + n : "" + n; }
|
|
45
|
+
|
|
46
|
+
// epoch 毫秒 → HH:MM:SS(本地时)
|
|
47
|
+
function formatClock(ms) {
|
|
48
|
+
if (typeof ms !== "number" || !isFinite(ms) || ms < 0) return "--:--:--";
|
|
49
|
+
var d = new Date(ms);
|
|
50
|
+
return pad2(d.getHours()) + ":" + pad2(d.getMinutes()) + ":" + pad2(d.getSeconds());
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 信号值预览:字符串原样(截断),对象 JSON(截断),nil 占位
|
|
54
|
+
function formatValue(value) {
|
|
55
|
+
if (value === null || value === undefined) return "nil";
|
|
56
|
+
if (typeof value === "string") {
|
|
57
|
+
return value.length > VALUE_PREVIEW_LIMIT ? value.slice(0, VALUE_PREVIEW_LIMIT) + "…" : value;
|
|
58
|
+
}
|
|
59
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
60
|
+
var text;
|
|
61
|
+
try {
|
|
62
|
+
text = JSON.stringify(value);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
text = String(value);
|
|
65
|
+
}
|
|
66
|
+
if (text === undefined) text = String(value);
|
|
67
|
+
return text.length > VALUE_PREVIEW_LIMIT ? text.slice(0, VALUE_PREVIEW_LIMIT) + "…" : text;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── 纯函数:信号表视图模型 ────────────────────────────────────
|
|
71
|
+
//
|
|
72
|
+
// graph 消息:{v, type:"graph", signals:[{id, subscribers}], effects:[{id, deps, runs, disposed}]}
|
|
73
|
+
// writeEntries:signal_write 消息流(DOM 层补记 receivedAt=收到时的 epoch 毫秒;
|
|
74
|
+
// 探针 t 为单调时钟秒,仅作 receivedAt 缺失时的回退)。
|
|
75
|
+
// runs 口径(契约):某 effect 的 deps 含该信号 id,则该信号 runs 取这个 effect 的
|
|
76
|
+
// runs——取第一个命中的 effect;disposed 的 effect 不参与(依赖图快照本已剔除)。
|
|
77
|
+
function buildSignalsModel(graphMsg, writeEntries) {
|
|
78
|
+
var signals = graphMsg && Array.isArray(graphMsg.signals) ? graphMsg.signals : [];
|
|
79
|
+
var effects = graphMsg && Array.isArray(graphMsg.effects) ? graphMsg.effects : [];
|
|
80
|
+
var writes = Array.isArray(writeEntries) ? writeEntries : [];
|
|
81
|
+
|
|
82
|
+
var rows = [];
|
|
83
|
+
var byId = new Map(); // String(id) → row,graph 顺序在前、仅写入流出现的在后
|
|
84
|
+
function rowFor(id) {
|
|
85
|
+
var key = String(id);
|
|
86
|
+
var row = byId.get(key);
|
|
87
|
+
if (!row) {
|
|
88
|
+
row = {
|
|
89
|
+
id: id,
|
|
90
|
+
shortId: shortId(id),
|
|
91
|
+
value: "nil", // 当前值 = 最近一次写入的 new
|
|
92
|
+
rawValue: undefined,
|
|
93
|
+
writes: 0, // 写入次数
|
|
94
|
+
lastWriteAt: null, // 最近写入 epoch 毫秒(闪烁/排序用)
|
|
95
|
+
lastWriteLabel: "--:--:--",
|
|
96
|
+
subscribers: null, // 订阅数(graph 未报 → null,DOM 显示 "–")
|
|
97
|
+
runs: null // runs(无依赖该信号的 effect → null)
|
|
98
|
+
};
|
|
99
|
+
byId.set(key, row);
|
|
100
|
+
rows.push(row);
|
|
101
|
+
}
|
|
102
|
+
return row;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
signals.forEach(function (sig) {
|
|
106
|
+
if (!sig || sig.id === null || sig.id === undefined) return;
|
|
107
|
+
var row = rowFor(sig.id);
|
|
108
|
+
row.subscribers = typeof sig.subscribers === "number" ? sig.subscribers : null;
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
rows.forEach(function (row) {
|
|
112
|
+
var key = String(row.id);
|
|
113
|
+
for (var i = 0; i < effects.length; i++) {
|
|
114
|
+
var eff = effects[i];
|
|
115
|
+
if (!eff || eff.disposed === true || !Array.isArray(eff.deps)) continue;
|
|
116
|
+
var hit = false;
|
|
117
|
+
for (var j = 0; j < eff.deps.length; j++) {
|
|
118
|
+
if (String(eff.deps[j]) === key) { hit = true; break; }
|
|
119
|
+
}
|
|
120
|
+
if (hit) {
|
|
121
|
+
row.runs = typeof eff.runs === "number" ? eff.runs : 0;
|
|
122
|
+
break; // 第一个命中即取(契约)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
writes.forEach(function (entry) {
|
|
128
|
+
if (!entry || entry.signal_id === null || entry.signal_id === undefined) return;
|
|
129
|
+
var row = rowFor(entry.signal_id);
|
|
130
|
+
row.writes += 1;
|
|
131
|
+
row.rawValue = entry.new;
|
|
132
|
+
row.value = formatValue(entry.new);
|
|
133
|
+
var ms = null;
|
|
134
|
+
if (typeof entry.receivedAt === "number") ms = entry.receivedAt;
|
|
135
|
+
else if (typeof entry.t === "number" && isFinite(entry.t) && entry.t > 1e9) ms = entry.t * 1000;
|
|
136
|
+
if (ms !== null) {
|
|
137
|
+
row.lastWriteAt = ms;
|
|
138
|
+
row.lastWriteLabel = formatClock(ms);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
return rows;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 值/次数/订阅/runs 有变化的行(String(id) 列表)——首帧不闪(全"变化"无意义)
|
|
146
|
+
function changedRowKeys(prevRows, nextRows) {
|
|
147
|
+
if (!prevRows || !prevRows.length || !nextRows) return [];
|
|
148
|
+
var prev = new Map();
|
|
149
|
+
prevRows.forEach(function (r) { prev.set(String(r.id), r); });
|
|
150
|
+
return nextRows.filter(function (r) {
|
|
151
|
+
var p = prev.get(String(r.id));
|
|
152
|
+
if (!p) return true; // 新出现的信号
|
|
153
|
+
return p.value !== r.value || p.writes !== r.writes ||
|
|
154
|
+
p.runs !== r.runs || p.subscribers !== r.subscribers;
|
|
155
|
+
}).map(function (r) { return String(r.id); });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ── 纯函数:组件树 → 缩进行 ───────────────────────────────────
|
|
159
|
+
//
|
|
160
|
+
// tree 消息(Citrine.debug_component_tree 快照):根条目
|
|
161
|
+
// {node_id, component, reuse_key, children:[…]}(children 嵌套同形)。
|
|
162
|
+
// 输出扁平行 [{depth, label, nodeId, reuseKey}],DOM 按 depth 缩进渲染。
|
|
163
|
+
function renderTreeLines(treeMsg) {
|
|
164
|
+
var lines = [];
|
|
165
|
+
if (!treeMsg || typeof treeMsg !== "object") return lines;
|
|
166
|
+
function walk(node, depth) {
|
|
167
|
+
if (!node || typeof node !== "object") return;
|
|
168
|
+
if (node.node_id === null || node.node_id === undefined) return; // 缺锚点不入树
|
|
169
|
+
var label = (node.component !== null && node.component !== undefined
|
|
170
|
+
? String(node.component) : "(component)") + " " + shortId(node.node_id);
|
|
171
|
+
if (node.reuse_key !== null && node.reuse_key !== undefined) {
|
|
172
|
+
label += " · " + String(node.reuse_key);
|
|
173
|
+
}
|
|
174
|
+
lines.push({
|
|
175
|
+
depth: depth,
|
|
176
|
+
label: label,
|
|
177
|
+
nodeId: node.node_id,
|
|
178
|
+
reuseKey: node.reuse_key !== null && node.reuse_key !== undefined
|
|
179
|
+
? String(node.reuse_key) : null
|
|
180
|
+
});
|
|
181
|
+
var children = Array.isArray(node.children) ? node.children : [];
|
|
182
|
+
for (var i = 0; i < children.length; i++) walk(children[i], depth + 1);
|
|
183
|
+
}
|
|
184
|
+
walk(treeMsg, 0);
|
|
185
|
+
return lines;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── 纯函数:依赖图视图模型(M4,Cytoscape elements)──────────
|
|
189
|
+
//
|
|
190
|
+
// graph 消息:{v, type:"graph", signals:[{id, subscribers}], effects:[{id, deps, runs, disposed}]}
|
|
191
|
+
// writeCounts:signal_write 流的面板侧累计(String(signal_id) → 次数,Map 或普通对象),
|
|
192
|
+
// 即 Signals 表"写入次数"的同源数据(那边由 buildSignalsModel 数 writeLog 得出)。
|
|
193
|
+
// 输出 Cytoscape elements 数组:signal 节点 id 前缀 "s"、data 带 subscribers/writes;
|
|
194
|
+
// effect 节点前缀 "e"、label 带 runs(fx短号 ×runs);边 signal→effect 来自 effect.deps,
|
|
195
|
+
// data.id = "s:<sig>|e:<fx>"(边箭头在样式层画在 target=effect 端)。
|
|
196
|
+
// 宽容口径:dep id 在 signals 里无对应 → 跳过该边(孤儿引用);disposed effect 整棵剔除。
|
|
197
|
+
|
|
198
|
+
// cytoscape 加载失败(vendor 脚本 404/被拦)时容器内的降级文案
|
|
199
|
+
var GRAPH_FALLBACK_TEXT = "依赖图不可用:cytoscape.js 未加载(请确认中继已升级 M4+,且 GET /vendor/cytoscape.min.js 可访问)";
|
|
200
|
+
|
|
201
|
+
var HEAT_BASE = "#33415c"; // 0 写入:灰蓝基础色
|
|
202
|
+
var HEAT_ACCENT = "#4f8cff"; // max 写入:高亮 accent(--accent token 同源)
|
|
203
|
+
|
|
204
|
+
// writeCounts 取值:Map / 普通对象都收(String(id) 键),非法值按 0
|
|
205
|
+
function countOf(writeCounts, id) {
|
|
206
|
+
if (!writeCounts) return 0;
|
|
207
|
+
var value;
|
|
208
|
+
if (typeof writeCounts.get === "function") {
|
|
209
|
+
value = writeCounts.get(String(id));
|
|
210
|
+
} else {
|
|
211
|
+
value = writeCounts[String(id)];
|
|
212
|
+
}
|
|
213
|
+
return typeof value === "number" && isFinite(value) && value > 0 ? Math.floor(value) : 0;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function buildGraphModel(graphMsg, writeCounts) {
|
|
217
|
+
var signals = graphMsg && Array.isArray(graphMsg.signals) ? graphMsg.signals : [];
|
|
218
|
+
var effects = graphMsg && Array.isArray(graphMsg.effects) ? graphMsg.effects : [];
|
|
219
|
+
var elements = [];
|
|
220
|
+
var signalIds = new Map(); // String(id) → true(边的孤儿判定 + 信号去重)
|
|
221
|
+
var seenEffects = new Map(); // String(id) → true(重复 effect 去重)
|
|
222
|
+
|
|
223
|
+
signals.forEach(function (sig) {
|
|
224
|
+
if (!sig || sig.id === null || sig.id === undefined) return;
|
|
225
|
+
var key = String(sig.id);
|
|
226
|
+
if (signalIds.has(key)) return;
|
|
227
|
+
signalIds.set(key, true);
|
|
228
|
+
elements.push({
|
|
229
|
+
group: "nodes",
|
|
230
|
+
data: {
|
|
231
|
+
id: "s:" + key,
|
|
232
|
+
label: shortId(sig.id),
|
|
233
|
+
kind: "signal",
|
|
234
|
+
sid: sig.id,
|
|
235
|
+
subscribers: typeof sig.subscribers === "number" ? sig.subscribers : 0,
|
|
236
|
+
writes: countOf(writeCounts, sig.id)
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
effects.forEach(function (eff) {
|
|
242
|
+
if (!eff || eff.id === null || eff.id === undefined) return;
|
|
243
|
+
if (eff.disposed === true) return; // disposed 不入图(节点与边一并剔除)
|
|
244
|
+
var ekey = String(eff.id);
|
|
245
|
+
if (seenEffects.has(ekey)) return;
|
|
246
|
+
seenEffects.set(ekey, true);
|
|
247
|
+
var runs = typeof eff.runs === "number" && isFinite(eff.runs) ? eff.runs : 0;
|
|
248
|
+
elements.push({
|
|
249
|
+
group: "nodes",
|
|
250
|
+
data: {
|
|
251
|
+
id: "e:" + ekey,
|
|
252
|
+
label: "fx" + shortId(eff.id) + " ×" + runs,
|
|
253
|
+
kind: "effect",
|
|
254
|
+
eid: eff.id,
|
|
255
|
+
runs: runs
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
var deps = Array.isArray(eff.deps) ? eff.deps : [];
|
|
259
|
+
deps.forEach(function (dep) {
|
|
260
|
+
if (dep === null || dep === undefined) return;
|
|
261
|
+
var dkey = String(dep);
|
|
262
|
+
if (!signalIds.has(dkey)) return; // 孤儿 dep:graph 信号表无此 id,跳过
|
|
263
|
+
var edgeId = "s:" + dkey + "|e:" + ekey;
|
|
264
|
+
for (var i = 0; i < elements.length; i++) {
|
|
265
|
+
if (elements[i].group === "edges" && elements[i].data.id === edgeId) return; // 同 effect 重复 dep 去重
|
|
266
|
+
}
|
|
267
|
+
elements.push({
|
|
268
|
+
group: "edges",
|
|
269
|
+
data: { id: edgeId, source: "s:" + dkey, target: "e:" + ekey }
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
return elements;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// heat:0 写入 → 灰蓝基础色,max 写入 → 高亮 accent,线性 RGB 插值(超出 max 钳到 accent)
|
|
278
|
+
function heatColor(writeCount, max) {
|
|
279
|
+
var ratio = 0;
|
|
280
|
+
if (typeof max === "number" && isFinite(max) && max > 0 &&
|
|
281
|
+
typeof writeCount === "number" && isFinite(writeCount) && writeCount > 0) {
|
|
282
|
+
ratio = Math.min(writeCount, max) / max;
|
|
283
|
+
}
|
|
284
|
+
var out = "#";
|
|
285
|
+
for (var i = 0; i < 3; i++) {
|
|
286
|
+
var a = parseInt(HEAT_BASE.slice(1 + i * 2, 3 + i * 2), 16);
|
|
287
|
+
var b = parseInt(HEAT_ACCENT.slice(1 + i * 2, 3 + i * 2), 16);
|
|
288
|
+
var v = Math.round(a + (b - a) * ratio);
|
|
289
|
+
out += (v < 16 ? "0" : "") + v.toString(16);
|
|
290
|
+
}
|
|
291
|
+
return out;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// 结构哈希源:节点/边数量 + 节点 id + 边端点 + 各 effect 的 runs。
|
|
295
|
+
// 故意不含 writes(heat 数据源)——heat 变了签名不变,只刷样式;runs/结构变了才重建。
|
|
296
|
+
function graphSignature(model) {
|
|
297
|
+
var nodes = [];
|
|
298
|
+
var edges = [];
|
|
299
|
+
(Array.isArray(model) ? model : []).forEach(function (elv) {
|
|
300
|
+
if (!elv || !elv.data) return;
|
|
301
|
+
if (elv.group === "edges") {
|
|
302
|
+
edges.push(elv.data.source + ">" + elv.data.target);
|
|
303
|
+
} else {
|
|
304
|
+
nodes.push(elv.data.id + "=" + (elv.data.kind === "effect" ? elv.data.runs : ""));
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
return "n" + nodes.length + ".e" + edges.length + ":" + nodes.join(",") + ";" + edges.join(",");
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// 模型里 signal 节点 writes 的最大值(heat 归一化分母;无写入 → 0,heatColor 退化基础色)
|
|
311
|
+
function maxWritesOf(model) {
|
|
312
|
+
var max = 0;
|
|
313
|
+
(Array.isArray(model) ? model : []).forEach(function (elv) {
|
|
314
|
+
if (elv && elv.group === "nodes" && elv.data &&
|
|
315
|
+
typeof elv.data.writes === "number" && elv.data.writes > max) {
|
|
316
|
+
max = elv.data.writes;
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
return max;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// 给模型节点填 data.heat(cytoscape 样式用 "background-color": "data(heat)" 映射)
|
|
323
|
+
function applyHeatColors(model, max) {
|
|
324
|
+
(Array.isArray(model) ? model : []).forEach(function (elv) {
|
|
325
|
+
if (elv && elv.group === "nodes" && elv.data) {
|
|
326
|
+
elv.data.heat = heatColor(elv.data.writes, max);
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
return model;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ── 纯函数:Timeline 视图模型(M4,纵向时间轴)───────────────
|
|
333
|
+
//
|
|
334
|
+
// 数据源是既有 SSE 流:event(flush_ids 关联)/ flush(effects 耗时)/ signal_write。
|
|
335
|
+
// 时间戳沿用 M3 口径:receivedAt(面板收到时的墙钟)优先,探针单调时钟 t(秒)回退,再退 now。
|
|
336
|
+
// 泳道:event → "事件",flush → "flush",signal_write → 短号泳道;写入流里前
|
|
337
|
+
// TIMELINE_LANE_LIMIT 个不同信号各占一泳道,超出/缺失归「其他信号」。
|
|
338
|
+
|
|
339
|
+
var TIMELINE_LANE_LIMIT = 8;
|
|
340
|
+
|
|
341
|
+
// flush 总耗时 = effects[].duration_ms 求和(缺字段/裸 id 条目按 0 计,宽容)
|
|
342
|
+
function flushTotalMs(effects) {
|
|
343
|
+
var total = 0;
|
|
344
|
+
(Array.isArray(effects) ? effects : []).forEach(function (eff) {
|
|
345
|
+
if (eff && typeof eff === "object" &&
|
|
346
|
+
typeof eff.duration_ms === "number" && isFinite(eff.duration_ms)) {
|
|
347
|
+
total += eff.duration_ms;
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
return total;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// 泳道归类:event/flush 固定泳道(kind 或 type 字段都认),signal_write 查映射,无命中归「其他信号」
|
|
354
|
+
function laneOf(entry, signalLanes) {
|
|
355
|
+
if (!entry || typeof entry !== "object") return "其他信号";
|
|
356
|
+
var kind = entry.kind || entry.type;
|
|
357
|
+
if (kind === "event") return "事件";
|
|
358
|
+
if (kind === "flush") return "flush";
|
|
359
|
+
if (entry.signal_id === null || entry.signal_id === undefined) return "其他信号";
|
|
360
|
+
var key = String(entry.signal_id);
|
|
361
|
+
if (signalLanes && typeof signalLanes.has === "function" && signalLanes.has(key)) {
|
|
362
|
+
return signalLanes.get(key);
|
|
363
|
+
}
|
|
364
|
+
return "其他信号";
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// 时间戳口径:receivedAt → t(单调时钟秒,>1e9 才认)→ fallbackMs
|
|
368
|
+
function entryTimeMs(entry, fallbackMs) {
|
|
369
|
+
if (entry && typeof entry.receivedAt === "number" && isFinite(entry.receivedAt)) {
|
|
370
|
+
return entry.receivedAt;
|
|
371
|
+
}
|
|
372
|
+
if (entry && typeof entry.t === "number" && isFinite(entry.t) && entry.t > 1e9) {
|
|
373
|
+
return entry.t * 1000;
|
|
374
|
+
}
|
|
375
|
+
return typeof fallbackMs === "number" && isFinite(fallbackMs) ? fallbackMs : 0;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// HH:MM:SS.mmm(本地时);非法输入兜底
|
|
379
|
+
function formatTimelineTime(ms) {
|
|
380
|
+
if (typeof ms !== "number" || !isFinite(ms) || ms < 0) return "--:--:--.---";
|
|
381
|
+
var d = new Date(ms);
|
|
382
|
+
return pad2(d.getHours()) + ":" + pad2(d.getMinutes()) + ":" + pad2(d.getSeconds()) +
|
|
383
|
+
"." + ("00" + d.getMilliseconds()).slice(-3);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// 环形缓冲:丢弃最旧,保留最近 cap 条(M1 ring 口径)
|
|
387
|
+
function trimBuffer(buf, cap) {
|
|
388
|
+
if (!Array.isArray(buf)) return buf;
|
|
389
|
+
var limit = typeof cap === "number" && isFinite(cap) && cap > 0 ? Math.floor(cap) : 0;
|
|
390
|
+
if (limit > 0 && buf.length > limit) buf.splice(0, buf.length - limit);
|
|
391
|
+
return buf;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// 三路缓冲 → 行模型(最新在上):{id, time, lane, label, kind, links}
|
|
395
|
+
// links 仅 event 行有:其 flush_ids 对应的 flush 行 id("flush#<id>"),供关联高亮
|
|
396
|
+
function buildTimelineRows(eventBuf, flushBuf, writeBuf, now) {
|
|
397
|
+
var items = [];
|
|
398
|
+
function collect(buf, kind) {
|
|
399
|
+
(Array.isArray(buf) ? buf : []).forEach(function (entry, i) {
|
|
400
|
+
if (!entry || typeof entry !== "object") return;
|
|
401
|
+
items.push({
|
|
402
|
+
entry: entry,
|
|
403
|
+
kind: kind,
|
|
404
|
+
seq: typeof entry.seq === "number" && isFinite(entry.seq) ? entry.seq : i
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
collect(eventBuf, "event");
|
|
409
|
+
collect(flushBuf, "flush");
|
|
410
|
+
collect(writeBuf, "signal_write");
|
|
411
|
+
|
|
412
|
+
// 信号泳道映射:写入流前 8 个不同信号(出现顺序)各占一短号泳道
|
|
413
|
+
var seenIds = [];
|
|
414
|
+
(Array.isArray(writeBuf) ? writeBuf : []).forEach(function (entry) {
|
|
415
|
+
if (!entry || entry.signal_id === null || entry.signal_id === undefined) return;
|
|
416
|
+
var key = String(entry.signal_id);
|
|
417
|
+
if (seenIds.indexOf(key) === -1) seenIds.push(key);
|
|
418
|
+
});
|
|
419
|
+
var signalLanes = new Map();
|
|
420
|
+
seenIds.slice(0, TIMELINE_LANE_LIMIT).forEach(function (key) {
|
|
421
|
+
for (var i = 0; i < (Array.isArray(writeBuf) ? writeBuf.length : 0); i++) {
|
|
422
|
+
var entry = writeBuf[i];
|
|
423
|
+
if (entry && String(entry.signal_id) === key) {
|
|
424
|
+
signalLanes.set(key, shortId(entry.signal_id));
|
|
425
|
+
break;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
items.sort(function (a, b) {
|
|
431
|
+
return entryTimeMs(b.entry, now) - entryTimeMs(a.entry, now) || b.seq - a.seq;
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
return items.map(function (item, index) {
|
|
435
|
+
var e = item.entry;
|
|
436
|
+
var time = entryTimeMs(e, now);
|
|
437
|
+
if (item.kind === "event") {
|
|
438
|
+
var label = String(e.event_type !== null && e.event_type !== undefined ? e.event_type : "event") +
|
|
439
|
+
" @" + String(e.target_component !== null && e.target_component !== undefined
|
|
440
|
+
? e.target_component : "?");
|
|
441
|
+
if (e.handler_name !== null && e.handler_name !== undefined) {
|
|
442
|
+
label += " · " + String(e.handler_name);
|
|
443
|
+
}
|
|
444
|
+
return {
|
|
445
|
+
id: "event#" + index,
|
|
446
|
+
time: time,
|
|
447
|
+
lane: laneOf(e),
|
|
448
|
+
kind: "event",
|
|
449
|
+
label: label,
|
|
450
|
+
links: (Array.isArray(e.flush_ids) ? e.flush_ids : []).map(function (fid) {
|
|
451
|
+
return "flush#" + fid;
|
|
452
|
+
})
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
if (item.kind === "flush") {
|
|
456
|
+
var effects = Array.isArray(e.effects) ? e.effects : [];
|
|
457
|
+
return {
|
|
458
|
+
id: "flush#" + (e.flush_id !== null && e.flush_id !== undefined ? e.flush_id : index),
|
|
459
|
+
time: time,
|
|
460
|
+
lane: laneOf(e),
|
|
461
|
+
kind: "flush",
|
|
462
|
+
label: effects.length + " fx · " + flushTotalMs(effects) + "ms",
|
|
463
|
+
links: []
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
return {
|
|
467
|
+
id: "write#" + index,
|
|
468
|
+
time: time,
|
|
469
|
+
lane: laneOf(e, signalLanes),
|
|
470
|
+
kind: "signal_write",
|
|
471
|
+
label: formatValue(e.old) + " → " + formatValue(e.new),
|
|
472
|
+
links: []
|
|
473
|
+
};
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// ── 纯函数:Inspector 信号改值(M4)────────────────────────
|
|
478
|
+
//
|
|
479
|
+
// 输入框宽松解析:JSON 优先(数字/布尔/null/引号串/对象/数组),失败退数字字面量,
|
|
480
|
+
// 再退原样 string。signal_id/effect_id 发 cmd 时须为数字(bridge 按 object_id === 严格比)。
|
|
481
|
+
function parseLooseValue(text) {
|
|
482
|
+
if (typeof text !== "string") return text === undefined || text === null ? "" : text;
|
|
483
|
+
var trimmed = text.trim();
|
|
484
|
+
if (trimmed === "") return "";
|
|
485
|
+
try {
|
|
486
|
+
return JSON.parse(trimmed);
|
|
487
|
+
} catch (err) {
|
|
488
|
+
if (/^-?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/.test(trimmed)) return Number(trimmed);
|
|
489
|
+
return text; // 其余原样 string
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// 选中信号的第一个依赖 effect(graph 里 deps 含该 signal 且未 disposed)→ effect_id 或 null
|
|
494
|
+
function inspectorEffectFor(graphMsg, signalId) {
|
|
495
|
+
var effects = graphMsg && Array.isArray(graphMsg.effects) ? graphMsg.effects : [];
|
|
496
|
+
var key = String(signalId);
|
|
497
|
+
for (var i = 0; i < effects.length; i++) {
|
|
498
|
+
var eff = effects[i];
|
|
499
|
+
if (!eff || eff.id === null || eff.id === undefined || eff.disposed === true) continue;
|
|
500
|
+
var deps = Array.isArray(eff.deps) ? eff.deps : [];
|
|
501
|
+
for (var j = 0; j < deps.length; j++) {
|
|
502
|
+
if (String(deps[j]) === key) return eff.id;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
return null;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// 写值回执判定:awaiting(窗内未到)/ confirmed(窗内收到同信号写入)/ timeout(超窗/迟达)
|
|
509
|
+
function receiptState(sentAt, echoAt, nowMs, windowMs) {
|
|
510
|
+
var win = typeof windowMs === "number" && windowMs > 0 ? windowMs : 2000;
|
|
511
|
+
if (typeof echoAt === "number" && isFinite(echoAt) && echoAt >= sentAt) {
|
|
512
|
+
return echoAt - sentAt <= win ? "confirmed" : "timeout";
|
|
513
|
+
}
|
|
514
|
+
if (typeof nowMs !== "number" || !isFinite(nowMs) || nowMs - sentAt < win) return "awaiting";
|
|
515
|
+
return "timeout";
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// ── node 测试入口(契约:纯函数挂 module.exports 分支)────────
|
|
519
|
+
if (typeof module !== "undefined" && module.exports) {
|
|
520
|
+
module.exports = {
|
|
521
|
+
PROTOCOL_VERSION: PROTOCOL_VERSION,
|
|
522
|
+
isValidMessage: isValidMessage,
|
|
523
|
+
parseSSEData: parseSSEData,
|
|
524
|
+
shortId: shortId,
|
|
525
|
+
formatClock: formatClock,
|
|
526
|
+
formatValue: formatValue,
|
|
527
|
+
buildSignalsModel: buildSignalsModel,
|
|
528
|
+
changedRowKeys: changedRowKeys,
|
|
529
|
+
renderTreeLines: renderTreeLines,
|
|
530
|
+
GRAPH_FALLBACK_TEXT: GRAPH_FALLBACK_TEXT,
|
|
531
|
+
HEAT_BASE: HEAT_BASE,
|
|
532
|
+
HEAT_ACCENT: HEAT_ACCENT,
|
|
533
|
+
countOf: countOf,
|
|
534
|
+
buildGraphModel: buildGraphModel,
|
|
535
|
+
heatColor: heatColor,
|
|
536
|
+
graphSignature: graphSignature,
|
|
537
|
+
maxWritesOf: maxWritesOf,
|
|
538
|
+
applyHeatColors: applyHeatColors,
|
|
539
|
+
TIMELINE_LANE_LIMIT: TIMELINE_LANE_LIMIT,
|
|
540
|
+
flushTotalMs: flushTotalMs,
|
|
541
|
+
laneOf: laneOf,
|
|
542
|
+
entryTimeMs: entryTimeMs,
|
|
543
|
+
formatTimelineTime: formatTimelineTime,
|
|
544
|
+
trimBuffer: trimBuffer,
|
|
545
|
+
buildTimelineRows: buildTimelineRows,
|
|
546
|
+
parseLooseValue: parseLooseValue,
|
|
547
|
+
inspectorEffectFor: inspectorEffectFor,
|
|
548
|
+
receiptState: receiptState
|
|
549
|
+
};
|
|
550
|
+
return; // node 环境到此为止,下面是浏览器 DOM 绑定
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// ── 浏览器:DOM 绑定 ─────────────────────────────────────────
|
|
554
|
+
|
|
555
|
+
if (typeof document === "undefined") return;
|
|
556
|
+
|
|
557
|
+
var es = null;
|
|
558
|
+
var writeLog = [];
|
|
559
|
+
var writeCounts = new Map(); // String(signal_id) → 累计写入次数(依赖图 heat 数据源,与信号表同源不重复计数)
|
|
560
|
+
var eventBuf = []; // Timeline:event 消息环形缓冲(seq = 到达序号,同毫秒时间戳的排序键)
|
|
561
|
+
var flushBuf = []; // Timeline:flush 消息环形缓冲
|
|
562
|
+
var arrivalSeq = 0;
|
|
563
|
+
var linkedFlushes = {}; // Timeline:event 关联高亮的 flush 行 id 集("flush#7" → true)
|
|
564
|
+
var selectedKey = null; // Inspector:选中信号 String(id);null = 未选中
|
|
565
|
+
var pendingWrite = null; // Inspector:{signalId, sentAt} 等待 signal_write 回执
|
|
566
|
+
var lastTreeMsg = null;
|
|
567
|
+
var lastGraphMsg = null;
|
|
568
|
+
var lastModel = [];
|
|
569
|
+
var cy = null; // cytoscape 实例(切到依赖图页签才惰性初始化)
|
|
570
|
+
var lastGraphSignature = null; // 上次渲染的图结构签名(签名同 → 只刷 heat,不同 → 重建)
|
|
571
|
+
var activeTab = "tree";
|
|
572
|
+
var everTree = false; // 组件树自动重发的前置:窗口已收到过 tree 消息
|
|
573
|
+
|
|
574
|
+
var TIMELINE_CAP = 200; // Timeline 环形容量(与 M1 ring 口径一致:丢最旧留最新)
|
|
575
|
+
var TIMELINE_RENDER_LIMIT = 100; // DOM 只渲染最近 100 行,防长会话膨胀
|
|
576
|
+
var RECEIPT_WINDOW_MS = 2000; // 写值回执判定窗口
|
|
577
|
+
|
|
578
|
+
function el(id) { return document.getElementById(id); }
|
|
579
|
+
|
|
580
|
+
function postCmd(cmd, params) {
|
|
581
|
+
var body = { v: PROTOCOL_VERSION, type: "cmd", cmd: cmd };
|
|
582
|
+
if (params) {
|
|
583
|
+
for (var key in params) {
|
|
584
|
+
if (Object.prototype.hasOwnProperty.call(params, key)) body[key] = params[key];
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
fetch("/__devtools/cmd", {
|
|
588
|
+
method: "POST",
|
|
589
|
+
headers: { "Content-Type": "application/json" },
|
|
590
|
+
body: JSON.stringify(body)
|
|
591
|
+
}).catch(function (err) {
|
|
592
|
+
console.warn("[citrine-devtools] 指令发送失败:", err);
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// 打开与点刷新按钮时:两个快照都重新请求
|
|
597
|
+
function requestAll() {
|
|
598
|
+
postCmd("request_graph");
|
|
599
|
+
postCmd("request_tree");
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function setConnected(on) {
|
|
603
|
+
el("dot").className = on ? "dot on" : "dot";
|
|
604
|
+
el("status-text").textContent = on ? "已连接" : "未连接";
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function connect() {
|
|
608
|
+
es = new EventSource("/__devtools/stream");
|
|
609
|
+
es.onopen = function () {
|
|
610
|
+
setConnected(true); // 绿点(SSE 自动重连后也会触发,顺带重新请求快照)
|
|
611
|
+
requestAll();
|
|
612
|
+
};
|
|
613
|
+
es.onerror = function () {
|
|
614
|
+
setConnected(false); // 红点;EventSource 自动重连
|
|
615
|
+
};
|
|
616
|
+
es.onmessage = function (e) {
|
|
617
|
+
var message = parseSSEData(e.data);
|
|
618
|
+
if (!message) return;
|
|
619
|
+
switch (message.type) {
|
|
620
|
+
case "tree":
|
|
621
|
+
lastTreeMsg = message;
|
|
622
|
+
everTree = true;
|
|
623
|
+
if (activeTab === "tree") renderTree();
|
|
624
|
+
break;
|
|
625
|
+
case "graph":
|
|
626
|
+
lastGraphMsg = message; // 依赖图/信号表共享一份 graph 快照(runs/订阅数可能变)
|
|
627
|
+
renderSignals();
|
|
628
|
+
if (activeTab === "graph") renderGraph();
|
|
629
|
+
break;
|
|
630
|
+
case "signal_write":
|
|
631
|
+
message.receivedAt = Date.now(); // 面板收到时刻 = 展示用的 wall clock
|
|
632
|
+
writeLog.push(message);
|
|
633
|
+
if (writeLog.length > WRITE_LOG_LIMIT) {
|
|
634
|
+
writeLog.splice(0, writeLog.length - WRITE_LOG_LIMIT);
|
|
635
|
+
}
|
|
636
|
+
var wkey = String(message.signal_id);
|
|
637
|
+
writeCounts.set(wkey, (writeCounts.get(wkey) || 0) + 1); // heat 累计(信号表的次数由 buildSignalsModel 数 writeLog 得出,不复用此值以免双口径)
|
|
638
|
+
if (pendingWrite && wkey === pendingWrite.signalId &&
|
|
639
|
+
receiptState(pendingWrite.sentAt, Date.now(), Date.now(), RECEIPT_WINDOW_MS) !== "timeout") {
|
|
640
|
+
pendingWrite = null;
|
|
641
|
+
setInspectorStatus("✓ 已生效", "ok");
|
|
642
|
+
}
|
|
643
|
+
renderSignals();
|
|
644
|
+
if (activeTab === "graph") renderGraph();
|
|
645
|
+
if (activeTab === "timeline") renderTimeline();
|
|
646
|
+
break;
|
|
647
|
+
case "event":
|
|
648
|
+
message.receivedAt = Date.now();
|
|
649
|
+
message.seq = arrivalSeq++;
|
|
650
|
+
eventBuf.push(message);
|
|
651
|
+
trimBuffer(eventBuf, TIMELINE_CAP);
|
|
652
|
+
if (activeTab === "timeline") {
|
|
653
|
+
perfMeasureEvent(message);
|
|
654
|
+
renderTimeline();
|
|
655
|
+
}
|
|
656
|
+
break;
|
|
657
|
+
case "flush":
|
|
658
|
+
message.receivedAt = Date.now();
|
|
659
|
+
message.seq = arrivalSeq++;
|
|
660
|
+
flushBuf.push(message);
|
|
661
|
+
trimBuffer(flushBuf, TIMELINE_CAP);
|
|
662
|
+
if (activeTab === "timeline") {
|
|
663
|
+
perfMeasureFlush(message);
|
|
664
|
+
renderTimeline();
|
|
665
|
+
}
|
|
666
|
+
break;
|
|
667
|
+
case "error":
|
|
668
|
+
pendingWrite = null; // set_signal/force_rerun 失败不会有回执,直接展示错误
|
|
669
|
+
setInspectorStatus(String(message.error || "未知错误"), "err");
|
|
670
|
+
break;
|
|
671
|
+
default:
|
|
672
|
+
break; // cmd(面板自身指令广播)/ flush / event / error / hello:v1 面板不消费
|
|
673
|
+
}
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function emptyNode(text, colspan) {
|
|
678
|
+
var div = document.createElement("div");
|
|
679
|
+
div.className = "empty";
|
|
680
|
+
div.textContent = text;
|
|
681
|
+
return div;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function renderTree() {
|
|
685
|
+
var box = el("tree");
|
|
686
|
+
box.textContent = "";
|
|
687
|
+
if (!lastTreeMsg) {
|
|
688
|
+
box.appendChild(emptyNode("等待 tree 消息…(被调试页需引入 bridge.js)"));
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
var lines = renderTreeLines(lastTreeMsg);
|
|
692
|
+
if (!lines.length) {
|
|
693
|
+
box.appendChild(emptyNode("tree 快照为空"));
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
lines.forEach(function (line) {
|
|
697
|
+
var div = document.createElement("div");
|
|
698
|
+
div.className = "tree-line";
|
|
699
|
+
div.style.paddingLeft = (8 + line.depth * 14) + "px";
|
|
700
|
+
div.textContent = line.label;
|
|
701
|
+
box.appendChild(div);
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function safeString(value) {
|
|
706
|
+
if (value === undefined) return "";
|
|
707
|
+
if (value === null) return "nil";
|
|
708
|
+
if (typeof value === "string") return value;
|
|
709
|
+
try {
|
|
710
|
+
var text = JSON.stringify(value);
|
|
711
|
+
return text === undefined ? String(value) : text;
|
|
712
|
+
} catch (err) {
|
|
713
|
+
return String(value);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function renderSignals() {
|
|
718
|
+
var model = buildSignalsModel(lastGraphMsg, writeLog);
|
|
719
|
+
var flashIds = changedRowKeys(lastModel, model);
|
|
720
|
+
lastModel = model;
|
|
721
|
+
if (activeTab !== "signals") return;
|
|
722
|
+
var tbody = el("signals-body");
|
|
723
|
+
tbody.textContent = "";
|
|
724
|
+
if (!model.length) {
|
|
725
|
+
var tr = document.createElement("tr");
|
|
726
|
+
var td = document.createElement("td");
|
|
727
|
+
td.className = "empty";
|
|
728
|
+
td.colSpan = 6;
|
|
729
|
+
td.textContent = "等待 graph / signal_write 消息…";
|
|
730
|
+
tr.appendChild(td);
|
|
731
|
+
tbody.appendChild(tr);
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
model.forEach(function (row) {
|
|
735
|
+
var tr = document.createElement("tr");
|
|
736
|
+
var classes = [];
|
|
737
|
+
if (flashIds.indexOf(String(row.id)) !== -1) classes.push("flash");
|
|
738
|
+
if (selectedKey === String(row.id)) classes.push("selected");
|
|
739
|
+
if (classes.length) tr.className = classes.join(" ");
|
|
740
|
+
tr.addEventListener("click", function () { toggleSelectSignal(row.id); });
|
|
741
|
+
var cells = [
|
|
742
|
+
row.shortId,
|
|
743
|
+
row.value,
|
|
744
|
+
String(row.writes),
|
|
745
|
+
row.lastWriteLabel,
|
|
746
|
+
row.subscribers === null ? "–" : String(row.subscribers),
|
|
747
|
+
row.runs === null ? "–" : String(row.runs)
|
|
748
|
+
];
|
|
749
|
+
cells.forEach(function (text, i) {
|
|
750
|
+
var td = document.createElement("td");
|
|
751
|
+
if (i === 1) {
|
|
752
|
+
td.className = "val";
|
|
753
|
+
td.title = safeString(row.rawValue); // 悬停看完整值
|
|
754
|
+
}
|
|
755
|
+
td.textContent = text;
|
|
756
|
+
tr.appendChild(td);
|
|
757
|
+
});
|
|
758
|
+
tbody.appendChild(tr);
|
|
759
|
+
});
|
|
760
|
+
renderInspectorBar();
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// ── 依赖图(M4):cytoscape canvas 渲染,切到页签才惰性初始化 ──
|
|
764
|
+
|
|
765
|
+
// heat 只经 data.heat 映射进样式;结构/数据变化经 updateCy 走签名比较
|
|
766
|
+
function graphNodeStyles() {
|
|
767
|
+
return [
|
|
768
|
+
{ selector: "node", style: {
|
|
769
|
+
label: "data(label)",
|
|
770
|
+
"font-size": 10,
|
|
771
|
+
color: "#c9d4e6",
|
|
772
|
+
"background-color": "data(heat)",
|
|
773
|
+
width: 22,
|
|
774
|
+
height: 22,
|
|
775
|
+
"transition-property": "background-color opacity",
|
|
776
|
+
"transition-duration": 0.3
|
|
777
|
+
} },
|
|
778
|
+
{ selector: 'node[kind = "signal"]', style: { shape: "rectangle" } },
|
|
779
|
+
{ selector: 'node[kind = "effect"]', style: { shape: "ellipse" } },
|
|
780
|
+
{ selector: "edge", style: {
|
|
781
|
+
width: 1.2,
|
|
782
|
+
"line-color": "#3a4763",
|
|
783
|
+
"target-arrow-shape": "triangle", // 箭头指向 target = effect
|
|
784
|
+
"target-arrow-color": "#3a4763",
|
|
785
|
+
"arrow-scale": 0.9,
|
|
786
|
+
"curve-style": "bezier"
|
|
787
|
+
} },
|
|
788
|
+
{ selector: ".dimmed", style: { opacity: 0.15 } }
|
|
789
|
+
];
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function bindGraphTap() {
|
|
793
|
+
cy.on("tap", "node", function (evt) {
|
|
794
|
+
var neighborhood = evt.target.closedNeighborhood(); // 点击节点 + 邻居(经边相连)
|
|
795
|
+
cy.elements().forEach(function (elv) {
|
|
796
|
+
if (neighborhood.contains(elv)) elv.removeClass("dimmed");
|
|
797
|
+
else elv.addClass("dimmed");
|
|
798
|
+
});
|
|
799
|
+
});
|
|
800
|
+
cy.on("tap", function (evt) {
|
|
801
|
+
if (evt.target === cy) cy.elements().removeClass("dimmed"); // 点空白恢复
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function initCy(model) {
|
|
806
|
+
var box = el("graph");
|
|
807
|
+
box.className = "";
|
|
808
|
+
box.textContent = "";
|
|
809
|
+
cy = window.cytoscape({
|
|
810
|
+
container: box,
|
|
811
|
+
elements: model,
|
|
812
|
+
layout: { name: "cose", animate: false },
|
|
813
|
+
style: graphNodeStyles()
|
|
814
|
+
});
|
|
815
|
+
lastGraphSignature = graphSignature(model);
|
|
816
|
+
bindGraphTap();
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// 签名同 → 结构未变(runs 也在签名里),只刷 heat 与 label/writes 等节点数据;
|
|
820
|
+
// 签名不同 → cy.elements().remove() 全量重建并重跑 cose
|
|
821
|
+
function updateCy(model) {
|
|
822
|
+
var signature = graphSignature(model);
|
|
823
|
+
if (signature === lastGraphSignature) {
|
|
824
|
+
var byId = new Map();
|
|
825
|
+
model.forEach(function (elv) {
|
|
826
|
+
if (elv.group === "nodes") byId.set(elv.data.id, elv.data);
|
|
827
|
+
});
|
|
828
|
+
cy.nodes().forEach(function (node) {
|
|
829
|
+
var data = byId.get(node.id());
|
|
830
|
+
if (data) node.data(data); // data(heat) 映射自动重刷背景色
|
|
831
|
+
});
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
lastGraphSignature = signature;
|
|
835
|
+
cy.elements().remove();
|
|
836
|
+
cy.add(model);
|
|
837
|
+
cy.layout({ name: "cose", animate: false }).run();
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function renderGraph() {
|
|
841
|
+
if (activeTab !== "graph") return;
|
|
842
|
+
var box = el("graph");
|
|
843
|
+
if (typeof window.cytoscape === "undefined") { // vendor 库未加载 → 降级文案
|
|
844
|
+
cy = null;
|
|
845
|
+
lastGraphSignature = null;
|
|
846
|
+
box.className = "empty";
|
|
847
|
+
box.textContent = GRAPH_FALLBACK_TEXT;
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
if (!lastGraphMsg) {
|
|
851
|
+
box.className = "empty";
|
|
852
|
+
box.textContent = "等待 graph 消息…(被调试页需引入 bridge.js)";
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
var model = buildGraphModel(lastGraphMsg, writeCounts);
|
|
856
|
+
applyHeatColors(model, maxWritesOf(model));
|
|
857
|
+
if (!cy) initCy(model);
|
|
858
|
+
else updateCy(model);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// ── Timeline(M4):纵向时间轴 + performance.measure 集成 ────
|
|
862
|
+
|
|
863
|
+
// DevTools Performance 面板集成:flush 作为耗时块(起始 = 收到时刻 - 总耗时),
|
|
864
|
+
// event 作为零长 instant 标记。老内核/非 Chrome 无此 API 时静默跳过。
|
|
865
|
+
function perfMeasureFlush(message) {
|
|
866
|
+
if (typeof performance === "undefined" || !performance ||
|
|
867
|
+
typeof performance.measure !== "function") return;
|
|
868
|
+
try {
|
|
869
|
+
var totalMs = flushTotalMs(message.effects);
|
|
870
|
+
performance.measure("citrine flush#" + message.flush_id, {
|
|
871
|
+
startTime: message.receivedAt - totalMs,
|
|
872
|
+
duration: totalMs,
|
|
873
|
+
detail: { trigger_signal_ids: message.trigger_signal_ids, effects: message.effects }
|
|
874
|
+
});
|
|
875
|
+
} catch (err) {
|
|
876
|
+
/* 静默跳过 */
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
function perfMeasureEvent(message) {
|
|
881
|
+
if (typeof performance === "undefined" || !performance ||
|
|
882
|
+
typeof performance.measure !== "function") return;
|
|
883
|
+
try {
|
|
884
|
+
performance.measure(
|
|
885
|
+
"citrine event " + message.event_type + "@" + message.target_component,
|
|
886
|
+
{ startTime: message.receivedAt, duration: 0, detail: message }
|
|
887
|
+
);
|
|
888
|
+
} catch (err) {
|
|
889
|
+
/* 静默跳过 */
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function timelineLaneClass(row) {
|
|
894
|
+
if (row.kind === "event") return "tl-chip-event";
|
|
895
|
+
if (row.kind === "flush") return "tl-chip-flush";
|
|
896
|
+
return row.lane === "其他信号" ? "tl-chip-other" : "tl-chip-signal";
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// 点击 event 行:其 links 全部已高亮 → 取消;否则整组高亮(再点取消)
|
|
900
|
+
function toggleEventLinks(links) {
|
|
901
|
+
return function () {
|
|
902
|
+
var allLinked = links.every(function (id) { return linkedFlushes[id]; });
|
|
903
|
+
links.forEach(function (id) {
|
|
904
|
+
if (allLinked) delete linkedFlushes[id];
|
|
905
|
+
else linkedFlushes[id] = true;
|
|
906
|
+
});
|
|
907
|
+
renderTimeline();
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function renderTimeline() {
|
|
912
|
+
if (activeTab !== "timeline") return;
|
|
913
|
+
var box = el("timeline");
|
|
914
|
+
var rows = buildTimelineRows(eventBuf, flushBuf, writeLog.slice(-TIMELINE_CAP), Date.now());
|
|
915
|
+
var view = rows.slice(0, TIMELINE_RENDER_LIMIT);
|
|
916
|
+
var scrollTop = box.scrollTop; // 新行插在顶部,渲染后恢复滚动位置
|
|
917
|
+
box.textContent = "";
|
|
918
|
+
if (!view.length) {
|
|
919
|
+
box.appendChild(emptyNode("等待 event / flush / signal_write 消息…"));
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
view.forEach(function (row) {
|
|
923
|
+
var div = document.createElement("div");
|
|
924
|
+
div.className = "tl-row tl-" + row.kind + (linkedFlushes[row.id] ? " linked" : "");
|
|
925
|
+
var time = document.createElement("span");
|
|
926
|
+
time.className = "tl-time";
|
|
927
|
+
time.textContent = "[" + formatTimelineTime(row.time) + "]";
|
|
928
|
+
var chip = document.createElement("span");
|
|
929
|
+
chip.className = "tl-chip " + timelineLaneClass(row);
|
|
930
|
+
chip.textContent = row.lane;
|
|
931
|
+
var label = document.createElement("span");
|
|
932
|
+
label.textContent = row.label;
|
|
933
|
+
div.appendChild(time);
|
|
934
|
+
div.appendChild(chip);
|
|
935
|
+
div.appendChild(label);
|
|
936
|
+
if (row.kind === "event" && row.links.length) {
|
|
937
|
+
div.title = "点击高亮关联 flush:" + row.links.join(", ");
|
|
938
|
+
div.addEventListener("click", toggleEventLinks(row.links));
|
|
939
|
+
}
|
|
940
|
+
box.appendChild(div);
|
|
941
|
+
});
|
|
942
|
+
box.scrollTop = scrollTop;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// ── Inspector(M4):信号在线改值(挂在 Signals 表格上)──────
|
|
946
|
+
|
|
947
|
+
function setInspectorStatus(text, kind) {
|
|
948
|
+
var status = el("inspector-status");
|
|
949
|
+
status.textContent = text;
|
|
950
|
+
status.className = kind || "";
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// bridge 按 object_id 严格比对(===):数字外观的 id 发 number,其余原样
|
|
954
|
+
function cmdId(key) {
|
|
955
|
+
return /^-?\d+$/.test(key) ? Number(key) : key;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// 详情条动态区刷新(输入框/按钮只绑定一次,避免打字时被重渲染清掉)
|
|
959
|
+
function renderInspectorBar() {
|
|
960
|
+
var bar = el("inspector");
|
|
961
|
+
if (!selectedKey) {
|
|
962
|
+
bar.hidden = true;
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
bar.hidden = false;
|
|
966
|
+
var row = null;
|
|
967
|
+
for (var i = 0; i < lastModel.length; i++) {
|
|
968
|
+
if (String(lastModel[i].id) === selectedKey) { row = lastModel[i]; break; }
|
|
969
|
+
}
|
|
970
|
+
el("inspector-sig").textContent = row ? row.shortId : "#" + selectedKey.slice(-4);
|
|
971
|
+
var valueEl = el("inspector-value");
|
|
972
|
+
valueEl.textContent = row ? "= " + row.value : "= ?";
|
|
973
|
+
if (row) valueEl.title = safeString(row.rawValue);
|
|
974
|
+
var effectId = inspectorEffectFor(lastGraphMsg, selectedKey);
|
|
975
|
+
var rerunBtn = el("inspector-rerun");
|
|
976
|
+
rerunBtn.disabled = effectId === null;
|
|
977
|
+
rerunBtn.title = effectId === null
|
|
978
|
+
? "选中信号没有未 dispose 的依赖 effect"
|
|
979
|
+
: "force_rerun fx#" + String(effectId).slice(-4);
|
|
980
|
+
if (effectId === null && !pendingWrite) {
|
|
981
|
+
setInspectorStatus("无依赖 effect,无法 force_rerun", "dim");
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
function toggleSelectSignal(id) {
|
|
986
|
+
var key = String(id);
|
|
987
|
+
selectedKey = selectedKey === key ? null : key; // 再点取消选中
|
|
988
|
+
pendingWrite = null;
|
|
989
|
+
el("inspector-input").value = "";
|
|
990
|
+
setInspectorStatus("", "");
|
|
991
|
+
renderSignals();
|
|
992
|
+
renderInspectorBar();
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
function bindInspector() {
|
|
996
|
+
el("inspector-write").addEventListener("click", function () {
|
|
997
|
+
if (!selectedKey) return;
|
|
998
|
+
var value = parseLooseValue(el("inspector-input").value);
|
|
999
|
+
var signalId = selectedKey;
|
|
1000
|
+
postCmd("set_signal", { signal_id: cmdId(signalId), value: value });
|
|
1001
|
+
pendingWrite = { signalId: signalId, sentAt: Date.now() };
|
|
1002
|
+
setInspectorStatus("等待回执…", "dim");
|
|
1003
|
+
setTimeout(function () { // 2s 无回执 → 超时复位
|
|
1004
|
+
if (pendingWrite && pendingWrite.signalId === signalId &&
|
|
1005
|
+
receiptState(pendingWrite.sentAt, null, Date.now(), RECEIPT_WINDOW_MS) === "timeout") {
|
|
1006
|
+
pendingWrite = null;
|
|
1007
|
+
setInspectorStatus("回执超时:未在 2s 内收到该信号的写入", "dim");
|
|
1008
|
+
}
|
|
1009
|
+
}, RECEIPT_WINDOW_MS);
|
|
1010
|
+
});
|
|
1011
|
+
el("inspector-rerun").addEventListener("click", function () {
|
|
1012
|
+
if (!selectedKey) return;
|
|
1013
|
+
var effectId = inspectorEffectFor(lastGraphMsg, selectedKey);
|
|
1014
|
+
if (effectId === null) return; // 按钮已禁用,双保险
|
|
1015
|
+
postCmd("force_rerun", { effect_id: cmdId(String(effectId)) });
|
|
1016
|
+
setInspectorStatus("force_rerun 已发送 fx#" + String(effectId).slice(-4), "dim");
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
function switchTab(tab) {
|
|
1021
|
+
activeTab = tab;
|
|
1022
|
+
var tabs = document.querySelectorAll(".tab");
|
|
1023
|
+
Array.prototype.forEach.call(tabs, function (btn) {
|
|
1024
|
+
btn.className = btn.getAttribute("data-tab") === tab ? "tab active" : "tab";
|
|
1025
|
+
});
|
|
1026
|
+
el("view-tree").hidden = tab !== "tree";
|
|
1027
|
+
el("view-signals").hidden = tab !== "signals";
|
|
1028
|
+
el("view-graph").hidden = tab !== "graph";
|
|
1029
|
+
el("view-timeline").hidden = tab !== "timeline";
|
|
1030
|
+
if (tab === "tree") renderTree();
|
|
1031
|
+
else if (tab === "signals") renderSignals();
|
|
1032
|
+
else if (tab === "graph") {
|
|
1033
|
+
renderGraph();
|
|
1034
|
+
postCmd("request_graph"); // 打开依赖图即要一份最新快照
|
|
1035
|
+
}
|
|
1036
|
+
else if (tab === "timeline") renderTimeline();
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
Array.prototype.forEach.call(document.querySelectorAll(".tab"), function (btn) {
|
|
1040
|
+
btn.addEventListener("click", function () { switchTab(btn.getAttribute("data-tab")); });
|
|
1041
|
+
});
|
|
1042
|
+
el("refresh").addEventListener("click", requestAll);
|
|
1043
|
+
bindInspector();
|
|
1044
|
+
|
|
1045
|
+
// 自动重发:组件树页签下 3s(窗口有 tree 消息时);信号列表/依赖图页签下 5s(共享 graph 快照)
|
|
1046
|
+
setInterval(function () {
|
|
1047
|
+
if (activeTab === "tree" && everTree) postCmd("request_tree");
|
|
1048
|
+
}, 3000);
|
|
1049
|
+
setInterval(function () {
|
|
1050
|
+
if (activeTab === "signals" || activeTab === "graph") postCmd("request_graph");
|
|
1051
|
+
}, 5000);
|
|
1052
|
+
|
|
1053
|
+
setConnected(false);
|
|
1054
|
+
renderTree();
|
|
1055
|
+
connect();
|
|
1056
|
+
})();
|