@zhhoceanfly/protoview 0.0.0-stage → 0.1.3
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.
- package/README.md +51 -3
- package/bin/protoview.js +3 -0
- package/dist/cli.js +115 -0
- package/dist/core/catalog.js +493 -0
- package/dist/core/catalog.js.map +1 -0
- package/dist/core/clientModel.js +111 -0
- package/dist/core/clientModel.js.map +1 -0
- package/dist/core/grpcInvoke.js +178 -0
- package/dist/core/grpcInvoke.js.map +1 -0
- package/dist/core/grpcStress.js +83 -0
- package/dist/core/grpcStress.js.map +1 -0
- package/dist/core/hostCore.js +292 -0
- package/dist/core/hostCore.js.map +1 -0
- package/dist/core/httpInvoke.js +152 -0
- package/dist/core/httpInvoke.js.map +1 -0
- package/dist/core/invokeDispatch.js +26 -0
- package/dist/core/invokeDispatch.js.map +1 -0
- package/dist/core/meta.js +158 -0
- package/dist/core/meta.js.map +1 -0
- package/dist/core/parser.js +638 -0
- package/dist/core/parser.js.map +1 -0
- package/dist/core/sourceCore.js +235 -0
- package/dist/core/sourceCore.js.map +1 -0
- package/dist/core/sseInvoke.js +244 -0
- package/dist/core/sseInvoke.js.map +1 -0
- package/dist/core/wsInvoke.js +326 -0
- package/dist/core/wsInvoke.js.map +1 -0
- package/dist/server.js +159 -0
- package/dist/uploadProtoDir.js +78 -0
- package/package.json +55 -6
- package/public/app.js +2025 -0
- package/public/donate-wechat.png +0 -0
- package/public/flow.js +1481 -0
- package/public/index.html +424 -0
- package/public/styles.css +1481 -0
package/public/flow.js
ADDED
|
@@ -0,0 +1,1481 @@
|
|
|
1
|
+
(() => {
|
|
2
|
+
const FLOW_KEY = "protoview.client.flow";
|
|
3
|
+
const api = () => window.ProtoViewClient;
|
|
4
|
+
const $ = (id) => document.getElementById(id);
|
|
5
|
+
|
|
6
|
+
const flow = {
|
|
7
|
+
nodes: [],
|
|
8
|
+
edges: [],
|
|
9
|
+
scale: 1,
|
|
10
|
+
panX: 40,
|
|
11
|
+
panY: 40,
|
|
12
|
+
menuNodeId: null,
|
|
13
|
+
editNodeId: null,
|
|
14
|
+
historyNodeId: null,
|
|
15
|
+
historySelectedId: null,
|
|
16
|
+
running: false,
|
|
17
|
+
stressing: false,
|
|
18
|
+
stressAbort: false,
|
|
19
|
+
};
|
|
20
|
+
const HISTORY_LIMIT = 80;
|
|
21
|
+
|
|
22
|
+
function load() {
|
|
23
|
+
try {
|
|
24
|
+
const raw = JSON.parse(localStorage.getItem(FLOW_KEY) || "{}");
|
|
25
|
+
flow.nodes = (Array.isArray(raw.nodes) ? raw.nodes : []).map((node) => ({
|
|
26
|
+
...node,
|
|
27
|
+
extracts: node.extracts && typeof node.extracts === "object" ? node.extracts : {},
|
|
28
|
+
history: Array.isArray(node.history) ? node.history : [],
|
|
29
|
+
}));
|
|
30
|
+
flow.edges = Array.isArray(raw.edges) ? raw.edges : [];
|
|
31
|
+
flow.scale = typeof raw.scale === "number" ? raw.scale : 1;
|
|
32
|
+
flow.panX = typeof raw.panX === "number" ? raw.panX : 40;
|
|
33
|
+
flow.panY = typeof raw.panY === "number" ? raw.panY : 40;
|
|
34
|
+
} catch { /* ignore */ }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function save() {
|
|
38
|
+
try {
|
|
39
|
+
localStorage.setItem(FLOW_KEY, JSON.stringify({
|
|
40
|
+
nodes: flow.nodes,
|
|
41
|
+
edges: flow.edges,
|
|
42
|
+
scale: flow.scale,
|
|
43
|
+
panX: flow.panX,
|
|
44
|
+
panY: flow.panY,
|
|
45
|
+
}));
|
|
46
|
+
} catch (error) {
|
|
47
|
+
console.warn("flow save failed", error);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function flowSnapshot() {
|
|
52
|
+
return {
|
|
53
|
+
version: 1,
|
|
54
|
+
kind: "protoview.flow",
|
|
55
|
+
exportedAt: new Date().toISOString(),
|
|
56
|
+
nodes: flow.nodes.map((node) => ({
|
|
57
|
+
id: node.id,
|
|
58
|
+
rpcId: node.rpcId,
|
|
59
|
+
name: node.name,
|
|
60
|
+
package: node.package,
|
|
61
|
+
x: node.x,
|
|
62
|
+
y: node.y,
|
|
63
|
+
message: node.message,
|
|
64
|
+
metadata: Array.isArray(node.metadata) ? node.metadata : [{ name: "", value: "" }],
|
|
65
|
+
extracts: node.extracts && typeof node.extracts === "object" ? node.extracts : {},
|
|
66
|
+
protocol: node.protocol || "grpc",
|
|
67
|
+
httpMethod: node.httpMethod || "POST",
|
|
68
|
+
httpPath: node.httpPath || "/",
|
|
69
|
+
envId: node.envId || "",
|
|
70
|
+
})),
|
|
71
|
+
edges: flow.edges.map((edge) => ({
|
|
72
|
+
id: edge.id,
|
|
73
|
+
from: edge.from,
|
|
74
|
+
to: edge.to,
|
|
75
|
+
})),
|
|
76
|
+
scale: flow.scale,
|
|
77
|
+
panX: flow.panX,
|
|
78
|
+
panY: flow.panY,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function exportFlow() {
|
|
83
|
+
const snapshot = flowSnapshot();
|
|
84
|
+
if (!snapshot.nodes.length) {
|
|
85
|
+
void notify("画布为空,没有可导出的节点");
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
// VS Code webview:交给扩展保存对话框;CLI / 浏览器:直接下载 JSON
|
|
89
|
+
if (api()?.isVsCodeHost?.() && api()?.postHost?.("exportFlow", snapshot)) return;
|
|
90
|
+
downloadJson(snapshot, `protoview-flow-${flowStamp()}.json`);
|
|
91
|
+
void notify("已导出链路配置");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function requestImportFlow() {
|
|
95
|
+
if (api()?.isVsCodeHost?.() && api()?.postHost?.("importFlow")) return;
|
|
96
|
+
pickJsonFile().then((data) => {
|
|
97
|
+
if (data) importData(data);
|
|
98
|
+
}).catch((error) => {
|
|
99
|
+
void notify(`导入失败:${error.message || error}`);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function downloadJson(data, filename) {
|
|
104
|
+
const blob = new Blob([`${JSON.stringify(data, null, 2)}\n`], { type: "application/json" });
|
|
105
|
+
const url = URL.createObjectURL(blob);
|
|
106
|
+
const a = document.createElement("a");
|
|
107
|
+
a.href = url;
|
|
108
|
+
a.download = filename;
|
|
109
|
+
a.rel = "noopener";
|
|
110
|
+
document.body.appendChild(a);
|
|
111
|
+
a.click();
|
|
112
|
+
a.remove();
|
|
113
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function pickJsonFile() {
|
|
117
|
+
return new Promise((resolve, reject) => {
|
|
118
|
+
const input = document.createElement("input");
|
|
119
|
+
input.type = "file";
|
|
120
|
+
input.accept = "application/json,.json";
|
|
121
|
+
input.style.display = "none";
|
|
122
|
+
input.addEventListener("change", () => {
|
|
123
|
+
const file = input.files?.[0];
|
|
124
|
+
input.remove();
|
|
125
|
+
if (!file) {
|
|
126
|
+
resolve(null);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const reader = new FileReader();
|
|
130
|
+
reader.onload = () => {
|
|
131
|
+
try {
|
|
132
|
+
resolve(JSON.parse(String(reader.result || "")));
|
|
133
|
+
} catch (error) {
|
|
134
|
+
reject(error);
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
reader.onerror = () => reject(new Error("读取文件失败"));
|
|
138
|
+
reader.readAsText(file, "utf-8");
|
|
139
|
+
});
|
|
140
|
+
document.body.appendChild(input);
|
|
141
|
+
input.click();
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function flowStamp() {
|
|
146
|
+
const d = new Date();
|
|
147
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
148
|
+
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function importData(raw) {
|
|
152
|
+
void applyImport(raw);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function applyImport(raw) {
|
|
156
|
+
const parsed = normalizeImport(raw);
|
|
157
|
+
if (!parsed.ok) {
|
|
158
|
+
void notify(parsed.error);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (flow.nodes.length) {
|
|
162
|
+
const ok = await askConfirm(`导入将替换当前画布(${flow.nodes.length} 个节点)。继续?`);
|
|
163
|
+
if (!ok) return;
|
|
164
|
+
}
|
|
165
|
+
flow.nodes = parsed.nodes;
|
|
166
|
+
flow.edges = parsed.edges;
|
|
167
|
+
flow.scale = parsed.scale;
|
|
168
|
+
flow.panX = parsed.panX;
|
|
169
|
+
flow.panY = parsed.panY;
|
|
170
|
+
flow.menuNodeId = null;
|
|
171
|
+
flow.editNodeId = null;
|
|
172
|
+
flow.historyNodeId = null;
|
|
173
|
+
flow.historySelectedId = null;
|
|
174
|
+
hideMenu();
|
|
175
|
+
save();
|
|
176
|
+
render();
|
|
177
|
+
applyTransform();
|
|
178
|
+
void notify(`已导入 ${flow.nodes.length} 个节点、${flow.edges.length} 条连线`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function normalizeImport(raw) {
|
|
182
|
+
if (!raw || typeof raw !== "object") {
|
|
183
|
+
return { ok: false, error: "导入失败:文件内容无效" };
|
|
184
|
+
}
|
|
185
|
+
if (raw.kind && raw.kind !== "protoview.flow") {
|
|
186
|
+
return { ok: false, error: "导入失败:不是 ProtoView 链路配置文件" };
|
|
187
|
+
}
|
|
188
|
+
const nodesIn = Array.isArray(raw.nodes) ? raw.nodes : null;
|
|
189
|
+
const edgesIn = Array.isArray(raw.edges) ? raw.edges : null;
|
|
190
|
+
if (!nodesIn || !edgesIn) {
|
|
191
|
+
return { ok: false, error: "导入失败:缺少 nodes / edges" };
|
|
192
|
+
}
|
|
193
|
+
const nodeIds = new Set();
|
|
194
|
+
const nodes = [];
|
|
195
|
+
for (const item of nodesIn) {
|
|
196
|
+
if (!item || typeof item !== "object") continue;
|
|
197
|
+
const id = String(item.id || "").trim() || uid("n");
|
|
198
|
+
const rpcId = String(item.rpcId || "").trim();
|
|
199
|
+
if (!rpcId) continue;
|
|
200
|
+
nodeIds.add(id);
|
|
201
|
+
nodes.push({
|
|
202
|
+
id,
|
|
203
|
+
rpcId,
|
|
204
|
+
name: String(item.name || rpcId),
|
|
205
|
+
package: String(item.package || ""),
|
|
206
|
+
x: Number.isFinite(Number(item.x)) ? Number(item.x) : 40,
|
|
207
|
+
y: Number.isFinite(Number(item.y)) ? Number(item.y) : 40,
|
|
208
|
+
message: item.message && typeof item.message === "object" ? item.message : {},
|
|
209
|
+
metadata: Array.isArray(item.metadata) && item.metadata.length
|
|
210
|
+
? item.metadata.map((row) => ({
|
|
211
|
+
name: String(row?.name || ""),
|
|
212
|
+
value: String(row?.value || ""),
|
|
213
|
+
}))
|
|
214
|
+
: [{ name: "", value: "" }],
|
|
215
|
+
extracts: item.extracts && typeof item.extracts === "object" ? { ...item.extracts } : {},
|
|
216
|
+
history: [],
|
|
217
|
+
protocol: normalizeNodeProtocol(item.protocol),
|
|
218
|
+
httpMethod: String(item.httpMethod || (item.protocol === "sse" ? "GET" : "POST")),
|
|
219
|
+
httpPath: String(item.httpPath || "/"),
|
|
220
|
+
envId: String(item.envId || ""),
|
|
221
|
+
result: null,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
const edges = [];
|
|
225
|
+
for (const item of edgesIn) {
|
|
226
|
+
if (!item || typeof item !== "object") continue;
|
|
227
|
+
const from = String(item.from || "");
|
|
228
|
+
const to = String(item.to || "");
|
|
229
|
+
if (!from || !to || from === to) continue;
|
|
230
|
+
if (!nodeIds.has(from) || !nodeIds.has(to)) continue;
|
|
231
|
+
edges.push({
|
|
232
|
+
id: String(item.id || "").trim() || uid("e"),
|
|
233
|
+
from,
|
|
234
|
+
to,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
ok: true,
|
|
239
|
+
nodes,
|
|
240
|
+
edges,
|
|
241
|
+
scale: Number.isFinite(Number(raw.scale)) ? Math.min(2.5, Math.max(0.35, Number(raw.scale))) : 1,
|
|
242
|
+
panX: Number.isFinite(Number(raw.panX)) ? Number(raw.panX) : 40,
|
|
243
|
+
panY: Number.isFinite(Number(raw.panY)) ? Number(raw.panY) : 40,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function uid(prefix) {
|
|
248
|
+
return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function esc(value) {
|
|
252
|
+
return String(value ?? "")
|
|
253
|
+
.replace(/&/g, "&")
|
|
254
|
+
.replace(/</g, "<")
|
|
255
|
+
.replace(/>/g, ">")
|
|
256
|
+
.replace(/"/g, """);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function isFlowOpen() {
|
|
260
|
+
const main = $("mainFlow");
|
|
261
|
+
return Boolean(main && !main.classList.contains("hidden"));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function openFlow() {
|
|
265
|
+
const client = $("mainClient");
|
|
266
|
+
const flowMain = $("mainFlow");
|
|
267
|
+
client?.classList.add("hidden");
|
|
268
|
+
flowMain?.classList.remove("hidden");
|
|
269
|
+
if (flowMain) {
|
|
270
|
+
flowMain.style.display = "flex";
|
|
271
|
+
flowMain.style.flexDirection = "column";
|
|
272
|
+
flowMain.style.height = "100%";
|
|
273
|
+
flowMain.style.minHeight = "0";
|
|
274
|
+
}
|
|
275
|
+
const stage = $("flowStage");
|
|
276
|
+
if (stage) {
|
|
277
|
+
stage.style.flex = "1 1 auto";
|
|
278
|
+
stage.style.minHeight = "240px";
|
|
279
|
+
stage.style.position = "relative";
|
|
280
|
+
}
|
|
281
|
+
document.getElementById("app")?.classList.add("flow-mode");
|
|
282
|
+
hideMenu();
|
|
283
|
+
// next frame so layout has non-zero size before placing nodes
|
|
284
|
+
requestAnimationFrame(() => render());
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function closeFlow() {
|
|
288
|
+
flow.stressAbort = true;
|
|
289
|
+
closeStressDrawer();
|
|
290
|
+
const flowMain = $("mainFlow");
|
|
291
|
+
flowMain?.classList.add("hidden");
|
|
292
|
+
if (flowMain) flowMain.style.display = "none";
|
|
293
|
+
const client = $("mainClient");
|
|
294
|
+
client?.classList.remove("hidden");
|
|
295
|
+
if (client) client.style.display = "";
|
|
296
|
+
document.getElementById("app")?.classList.remove("flow-mode");
|
|
297
|
+
hideMenu();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function applyTransform() {
|
|
301
|
+
const world = $("flowWorld");
|
|
302
|
+
const edges = $("flowEdges");
|
|
303
|
+
if (!world || !edges) return;
|
|
304
|
+
const t = `translate(${flow.panX}px, ${flow.panY}px) scale(${flow.scale})`;
|
|
305
|
+
world.style.transform = t;
|
|
306
|
+
edges.style.transform = t;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function render() {
|
|
310
|
+
const world = $("flowWorld");
|
|
311
|
+
const svg = $("flowEdges");
|
|
312
|
+
if (!world || !svg) return;
|
|
313
|
+
const empty = $("flowEmpty");
|
|
314
|
+
if (empty) empty.classList.toggle("hidden", flow.nodes.length > 0);
|
|
315
|
+
world.innerHTML = flow.nodes.map((node) => {
|
|
316
|
+
const hit = api()?.findRpc?.(node.rpcId);
|
|
317
|
+
const title = hit ? hit.rpc.name : (node.name || node.rpcId || "未知接口");
|
|
318
|
+
const proto = protocolLabel(node);
|
|
319
|
+
const sub = `${proto} · ${hit ? `${hit.pkg.name}.${hit.service.name}` : (node.package || "")}`;
|
|
320
|
+
const status = node.result?.ok === true ? "ok" : node.result?.ok === false ? "fail" : "";
|
|
321
|
+
const badge = node.result
|
|
322
|
+
? `<div class="flow-node-result ${status}">${esc(node.result.status || "")} · ${node.result.ms ?? "-"}ms</div>`
|
|
323
|
+
: `<div class="flow-node-result idle">未运行</div>`;
|
|
324
|
+
return `<div class="flow-node ${status}" data-node="${esc(node.id)}" style="left:${node.x}px;top:${node.y}px">
|
|
325
|
+
<div class="flow-node-head" data-drag="${esc(node.id)}">
|
|
326
|
+
<div class="flow-node-icon">⇢</div>
|
|
327
|
+
<div class="flow-node-text">
|
|
328
|
+
<strong>${esc(title)}</strong>
|
|
329
|
+
<span>${esc(sub)}</span>
|
|
330
|
+
</div>
|
|
331
|
+
</div>
|
|
332
|
+
${badge}
|
|
333
|
+
<div class="flow-port in" data-port-in="${esc(node.id)}" title="连入"></div>
|
|
334
|
+
<div class="flow-port out" data-port-out="${esc(node.id)}" title="连出"></div>
|
|
335
|
+
</div>`;
|
|
336
|
+
}).join("");
|
|
337
|
+
|
|
338
|
+
const lines = flow.edges.map((edge) => {
|
|
339
|
+
const from = flow.nodes.find((n) => n.id === edge.from);
|
|
340
|
+
const to = flow.nodes.find((n) => n.id === edge.to);
|
|
341
|
+
if (!from || !to) return "";
|
|
342
|
+
const x1 = from.x + 200;
|
|
343
|
+
const y1 = from.y + 36;
|
|
344
|
+
const x2 = to.x;
|
|
345
|
+
const y2 = to.y + 36;
|
|
346
|
+
const cls = edge.status === "ok" ? "ok" : edge.status === "fail" ? "fail" : "";
|
|
347
|
+
return `<path class="flow-edge ${cls}" data-edge="${esc(edge.id)}" d="M ${x1} ${y1} C ${x1 + 60} ${y1}, ${x2 - 60} ${y2}, ${x2} ${y2}" />`;
|
|
348
|
+
}).join("");
|
|
349
|
+
svg.innerHTML = lines;
|
|
350
|
+
svg.setAttribute("width", "4000");
|
|
351
|
+
svg.setAttribute("height", "3000");
|
|
352
|
+
applyTransform();
|
|
353
|
+
save();
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function canvasCenter() {
|
|
357
|
+
const stage = $("flowStage");
|
|
358
|
+
if (!stage) return { x: 120 + flow.nodes.length * 24, y: 100 + flow.nodes.length * 16 };
|
|
359
|
+
const rect = stage.getBoundingClientRect();
|
|
360
|
+
const midX = rect.left + rect.width * 0.35;
|
|
361
|
+
const midY = rect.top + rect.height * 0.35;
|
|
362
|
+
const pos = screenToWorld(midX, midY);
|
|
363
|
+
return {
|
|
364
|
+
x: pos.x - 100 + flow.nodes.length * 28,
|
|
365
|
+
y: pos.y - 30 + flow.nodes.length * 20,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function addNode(rpcId, x, y, label) {
|
|
370
|
+
if (!rpcId) return false;
|
|
371
|
+
try {
|
|
372
|
+
if (!isFlowOpen()) openFlow();
|
|
373
|
+
const hit = api()?.findRpc?.(rpcId);
|
|
374
|
+
const message = {};
|
|
375
|
+
if (hit) {
|
|
376
|
+
for (const field of hit.rpc.requestFields || []) {
|
|
377
|
+
try { message[field.name] = api()?.sampleField?.(field) ?? ""; }
|
|
378
|
+
catch { message[field.name] = ""; }
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
const place = () => {
|
|
382
|
+
const center = canvasCenter();
|
|
383
|
+
const nodeX = Number.isFinite(x) ? x : Math.max(40, center.x);
|
|
384
|
+
const nodeY = Number.isFinite(y) ? y : Math.max(40, center.y);
|
|
385
|
+
flow.nodes.push({
|
|
386
|
+
id: uid("n"),
|
|
387
|
+
rpcId,
|
|
388
|
+
name: hit?.rpc.name || label || rpcId,
|
|
389
|
+
package: hit ? `${hit.pkg.name}.${hit.service.name}` : "",
|
|
390
|
+
x: nodeX,
|
|
391
|
+
y: nodeY,
|
|
392
|
+
message,
|
|
393
|
+
metadata: [{ name: "", value: "" }],
|
|
394
|
+
extracts: {},
|
|
395
|
+
history: [],
|
|
396
|
+
protocol: "grpc",
|
|
397
|
+
httpMethod: hit?.rpc.httpVerb || "POST",
|
|
398
|
+
httpPath: hit?.rpc.httpPath || "/",
|
|
399
|
+
envId: api()?.getActiveEnvId?.() || "",
|
|
400
|
+
result: null,
|
|
401
|
+
});
|
|
402
|
+
save();
|
|
403
|
+
render();
|
|
404
|
+
};
|
|
405
|
+
// After openFlow, wait for layout so canvasCenter / stage size are valid
|
|
406
|
+
requestAnimationFrame(place);
|
|
407
|
+
return true;
|
|
408
|
+
} catch (error) {
|
|
409
|
+
console.error("addNode failed", error);
|
|
410
|
+
void notify(`添加接口失败:${error.message || error}`);
|
|
411
|
+
return false;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function addSelected() {
|
|
416
|
+
const id = api()?.getSelectedRpcId?.();
|
|
417
|
+
const label = api()?.getSelectedRpcLabel?.() || id;
|
|
418
|
+
if (!id) {
|
|
419
|
+
void notify("请先在左侧选中一个接口");
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
addNode(id, undefined, undefined, label);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function hideMenu() {
|
|
426
|
+
const menu = $("flowMenu");
|
|
427
|
+
if (!menu) return;
|
|
428
|
+
menu.classList.add("hidden");
|
|
429
|
+
menu.style.display = "none";
|
|
430
|
+
flow.menuNodeId = null;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** VS Code webview 中 window.confirm / alert 常被禁用,需自建对话框 */
|
|
434
|
+
function showDialog(message, { confirm = false } = {}) {
|
|
435
|
+
return new Promise((resolve) => {
|
|
436
|
+
const mask = document.createElement("div");
|
|
437
|
+
mask.className = "modal-mask show flow-confirm-mask";
|
|
438
|
+
mask.innerHTML = `<div class="modal flow-confirm-modal">
|
|
439
|
+
<div class="modal-head"><span>${confirm ? "确认" : "提示"}</span></div>
|
|
440
|
+
<div class="modal-body"><p style="margin:0">${esc(message)}</p></div>
|
|
441
|
+
<div class="modal-foot">
|
|
442
|
+
${confirm ? '<button class="btn" type="button" data-act="cancel">取消</button>' : ""}
|
|
443
|
+
<button class="btn primary" type="button" data-act="ok">确定</button>
|
|
444
|
+
</div>
|
|
445
|
+
</div>`;
|
|
446
|
+
const finish = (ok) => {
|
|
447
|
+
mask.remove();
|
|
448
|
+
resolve(ok);
|
|
449
|
+
};
|
|
450
|
+
mask.addEventListener("click", (event) => {
|
|
451
|
+
if (event.target === mask) finish(false);
|
|
452
|
+
const act = event.target.closest?.("[data-act]")?.getAttribute("data-act");
|
|
453
|
+
if (act === "ok") finish(true);
|
|
454
|
+
if (act === "cancel") finish(false);
|
|
455
|
+
});
|
|
456
|
+
document.body.appendChild(mask);
|
|
457
|
+
mask.querySelector('[data-act="ok"]')?.focus();
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
const askConfirm = (message) => showDialog(message, { confirm: true });
|
|
461
|
+
const notify = (message) => showDialog(message, { confirm: false });
|
|
462
|
+
|
|
463
|
+
function clearCanvas() {
|
|
464
|
+
void askConfirm("清空链路画布?此操作不可撤销。").then((ok) => {
|
|
465
|
+
if (!ok) return;
|
|
466
|
+
flow.nodes = [];
|
|
467
|
+
flow.edges = [];
|
|
468
|
+
hideMenu();
|
|
469
|
+
save();
|
|
470
|
+
render();
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function showMenu(x, y, nodeId) {
|
|
475
|
+
const menu = $("flowMenu");
|
|
476
|
+
if (!menu) return;
|
|
477
|
+
flow.menuNodeId = nodeId;
|
|
478
|
+
menu.style.left = `${x}px`;
|
|
479
|
+
menu.style.top = `${y}px`;
|
|
480
|
+
menu.style.display = "block";
|
|
481
|
+
menu.classList.remove("hidden");
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function getType(typeId) {
|
|
485
|
+
return api()?.getType?.(typeId);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function pathAttr(path) {
|
|
489
|
+
return esc(JSON.stringify(path));
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function parsePath(raw) {
|
|
493
|
+
try { return JSON.parse(raw); }
|
|
494
|
+
catch { return null; }
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function setByPath(root, path, value) {
|
|
498
|
+
if (!path?.length) return;
|
|
499
|
+
let cursor = root;
|
|
500
|
+
for (let i = 0; i < path.length - 1; i += 1) {
|
|
501
|
+
const key = path[i];
|
|
502
|
+
const next = path[i + 1];
|
|
503
|
+
if (typeof next === "number") {
|
|
504
|
+
if (!Array.isArray(cursor[key])) cursor[key] = [];
|
|
505
|
+
} else if (cursor[key] == null || typeof cursor[key] !== "object" || Array.isArray(cursor[key])) {
|
|
506
|
+
cursor[key] = {};
|
|
507
|
+
}
|
|
508
|
+
cursor = cursor[key];
|
|
509
|
+
}
|
|
510
|
+
cursor[path[path.length - 1]] = value;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function getByPath(root, path) {
|
|
514
|
+
let cursor = root;
|
|
515
|
+
for (const key of path) {
|
|
516
|
+
if (cursor == null) return undefined;
|
|
517
|
+
cursor = cursor[key];
|
|
518
|
+
}
|
|
519
|
+
return cursor;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function fieldTypeDef(field) {
|
|
523
|
+
return field?.typeId ? getType(field.typeId) : undefined;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function isMessageField(field) {
|
|
527
|
+
return fieldTypeDef(field)?.kind === "message";
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function displayScalar(value) {
|
|
531
|
+
if (value === undefined || value === null) return "";
|
|
532
|
+
if (typeof value === "object") return JSON.stringify(value);
|
|
533
|
+
return String(value);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function sampleItem(field) {
|
|
537
|
+
try {
|
|
538
|
+
return api()?.sampleField?.({ ...field, repeated: false }) ?? (isMessageField(field) ? {} : "");
|
|
539
|
+
} catch {
|
|
540
|
+
return isMessageField(field) ? {} : "";
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function typeLabel(field) {
|
|
545
|
+
const prefix = field.repeated ? "repeated " : field.key ? "map " : "";
|
|
546
|
+
if (field.key) return `${prefix}<${field.key}, ${field.type}>`;
|
|
547
|
+
return `${prefix}${field.type || ""}`;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function renderFieldHead(field, extra = "") {
|
|
551
|
+
const repClass = field.repeated ? " rep" : "";
|
|
552
|
+
return `<div class="flow-msg-head">
|
|
553
|
+
<span class="flow-msg-name">${esc(field.name)}</span>
|
|
554
|
+
<span class="flow-msg-type${repClass}">${esc(typeLabel(field))}</span>
|
|
555
|
+
${field.comment ? `<span class="flow-msg-comment">${esc(field.comment)}</span>` : ""}
|
|
556
|
+
${extra}
|
|
557
|
+
</div>`;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function renderScalarInput(field, value, path, opts = {}) {
|
|
561
|
+
const typeDef = fieldTypeDef(field);
|
|
562
|
+
const shown = displayScalar(value);
|
|
563
|
+
const ro = opts.readonly ? " readonly" : "";
|
|
564
|
+
const pathKey = opts.readonly ? "" : ` data-flow-msg-path="${pathAttr(path)}"`;
|
|
565
|
+
const listId = `flow-enum-${opts.readonly ? "r-" : ""}${esc(path.join("-"))}`;
|
|
566
|
+
if (typeDef?.kind === "enum" && Array.isArray(typeDef.values) && !opts.readonly) {
|
|
567
|
+
const options = typeDef.values.map((item) => {
|
|
568
|
+
const selected = String(item.number) === shown || item.name === shown ? " selected" : "";
|
|
569
|
+
return `<option value="${esc(String(item.number))}"${selected}>${esc(item.name)} = ${item.number}</option>`;
|
|
570
|
+
}).join("");
|
|
571
|
+
return `<div class="flow-msg-row">
|
|
572
|
+
<input list="${listId}"${pathKey} value="${esc(shown)}" placeholder="枚举值或 {{变量}}" />
|
|
573
|
+
<datalist id="${listId}">${options}</datalist>
|
|
574
|
+
</div>`;
|
|
575
|
+
}
|
|
576
|
+
return `<div class="flow-msg-row">
|
|
577
|
+
<input${pathKey} value="${esc(shown)}" placeholder="${opts.readonly ? "" : "值或 {{变量}}"}"${ro} />
|
|
578
|
+
</div>`;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function renderFields(fields, value, path = [], depth = 0, opts = {}) {
|
|
582
|
+
if (!fields?.length) return `<div class="flow-msg-empty">无字段</div>`;
|
|
583
|
+
if (depth > 8) return `<div class="flow-msg-empty">嵌套过深,已截断</div>`;
|
|
584
|
+
return fields.map((field) => renderField(field, value?.[field.name], [...path, field.name], depth, opts)).join("");
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function renderField(field, value, path, depth, opts = {}) {
|
|
588
|
+
// map<K,V> → JSON 编辑
|
|
589
|
+
if (field.key) {
|
|
590
|
+
const text = value && typeof value === "object" && !Array.isArray(value)
|
|
591
|
+
? JSON.stringify(value, null, 2)
|
|
592
|
+
: (typeof value === "string" ? value : "{}");
|
|
593
|
+
const mapAttr = opts.readonly ? "" : ` data-flow-msg-map="${pathAttr(path)}"`;
|
|
594
|
+
const ro = opts.readonly ? " readonly" : "";
|
|
595
|
+
return `<div class="flow-msg-field">
|
|
596
|
+
${renderFieldHead(field)}
|
|
597
|
+
<div class="flow-msg-row">
|
|
598
|
+
<textarea${mapAttr} placeholder='{"key":"value"} 或使用 {{变量}}'${ro}>${esc(text)}</textarea>
|
|
599
|
+
</div>
|
|
600
|
+
</div>`;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (field.repeated) {
|
|
604
|
+
const items = Array.isArray(value) ? value : [];
|
|
605
|
+
const itemField = { ...field, repeated: false };
|
|
606
|
+
const rows = items.map((item, index) => {
|
|
607
|
+
const itemPath = [...path, index];
|
|
608
|
+
const body = isMessageField(itemField)
|
|
609
|
+
? `<div class="flow-msg-children">${renderFields(fieldTypeDef(itemField)?.fields || [], item && typeof item === "object" ? item : {}, itemPath, depth + 1, opts)}</div>`
|
|
610
|
+
: renderScalarInput(itemField, item, itemPath, opts);
|
|
611
|
+
return `<div class="flow-msg-rep-item">
|
|
612
|
+
<div class="flow-msg-rep-bar">
|
|
613
|
+
<strong>#${index + 1}</strong>
|
|
614
|
+
${opts.readonly ? "" : `<button class="link danger" type="button" data-flow-rep-del="${pathAttr(path)}" data-flow-rep-index="${index}">删除</button>`}
|
|
615
|
+
</div>
|
|
616
|
+
${body}
|
|
617
|
+
</div>`;
|
|
618
|
+
}).join("");
|
|
619
|
+
return `<div class="flow-msg-field flow-msg-repeated">
|
|
620
|
+
${renderFieldHead(field)}
|
|
621
|
+
${rows || `<div class="flow-msg-empty">${opts.readonly ? "无数据" : "暂无元素"}</div>`}
|
|
622
|
+
${opts.readonly ? "" : `<div class="flow-msg-actions">
|
|
623
|
+
<button class="btn" type="button" data-flow-rep-add="${pathAttr(path)}">添加一项</button>
|
|
624
|
+
</div>`}
|
|
625
|
+
</div>`;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (isMessageField(field)) {
|
|
629
|
+
const typeDef = fieldTypeDef(field);
|
|
630
|
+
const obj = value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
631
|
+
return `<div class="flow-msg-field flow-msg-message">
|
|
632
|
+
${renderFieldHead(field)}
|
|
633
|
+
<div class="flow-msg-children">${renderFields(typeDef?.fields || [], obj, path, depth + 1, opts)}</div>
|
|
634
|
+
</div>`;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
return `<div class="flow-msg-field">
|
|
638
|
+
${renderFieldHead(field)}
|
|
639
|
+
${renderScalarInput(field, value, path, opts)}
|
|
640
|
+
</div>`;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function dottedPath(path) {
|
|
644
|
+
return (path || []).join(".");
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function valueAtDotted(body, pathKey) {
|
|
648
|
+
if (!pathKey || body == null) return undefined;
|
|
649
|
+
if (typeof body !== "object") return undefined;
|
|
650
|
+
if (Object.prototype.hasOwnProperty.call(body, pathKey)) return body[pathKey];
|
|
651
|
+
const parts = String(pathKey).split(".");
|
|
652
|
+
let cursor = body;
|
|
653
|
+
for (const part of parts) {
|
|
654
|
+
if (cursor == null || typeof cursor !== "object") return undefined;
|
|
655
|
+
if (Object.prototype.hasOwnProperty.call(cursor, part)) {
|
|
656
|
+
cursor = cursor[part];
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
const hit = Object.keys(cursor).find((key) => key.toLowerCase() === part.toLowerCase());
|
|
660
|
+
if (hit === undefined) return undefined;
|
|
661
|
+
cursor = cursor[hit];
|
|
662
|
+
}
|
|
663
|
+
return cursor;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function pushHistory(node, entry) {
|
|
667
|
+
if (!node) return;
|
|
668
|
+
if (!Array.isArray(node.history)) node.history = [];
|
|
669
|
+
node.history.unshift({
|
|
670
|
+
id: uid("h"),
|
|
671
|
+
at: Date.now(),
|
|
672
|
+
...entry,
|
|
673
|
+
});
|
|
674
|
+
if (node.history.length > HISTORY_LIMIT) node.history.length = HISTORY_LIMIT;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function formatHistoryTime(ts) {
|
|
678
|
+
try {
|
|
679
|
+
return new Date(ts).toLocaleString();
|
|
680
|
+
} catch {
|
|
681
|
+
return String(ts || "");
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function collectMessageFromDom(rootEl) {
|
|
686
|
+
const message = {};
|
|
687
|
+
if (!rootEl) return message;
|
|
688
|
+
for (const input of rootEl.querySelectorAll("[data-flow-msg-path]")) {
|
|
689
|
+
const path = parsePath(input.getAttribute("data-flow-msg-path"));
|
|
690
|
+
if (!path) continue;
|
|
691
|
+
setByPath(message, path, input.value);
|
|
692
|
+
}
|
|
693
|
+
for (const area of rootEl.querySelectorAll("[data-flow-msg-map]")) {
|
|
694
|
+
const path = parsePath(area.getAttribute("data-flow-msg-map"));
|
|
695
|
+
if (!path) continue;
|
|
696
|
+
const raw = area.value.trim();
|
|
697
|
+
if (!raw) {
|
|
698
|
+
setByPath(message, path, {});
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
701
|
+
if (raw.includes("{{")) {
|
|
702
|
+
setByPath(message, path, raw);
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
705
|
+
try { setByPath(message, path, JSON.parse(raw)); }
|
|
706
|
+
catch { setByPath(message, path, raw); }
|
|
707
|
+
}
|
|
708
|
+
// Ensure repeated paths that only have structural items keep array shape
|
|
709
|
+
for (const btn of rootEl.querySelectorAll("[data-flow-rep-add]")) {
|
|
710
|
+
const path = parsePath(btn.getAttribute("data-flow-rep-add"));
|
|
711
|
+
if (!path) continue;
|
|
712
|
+
if (getByPath(message, path) === undefined) setByPath(message, path, []);
|
|
713
|
+
}
|
|
714
|
+
return message;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function syncEditMessage() {
|
|
718
|
+
const node = flow.nodes.find((n) => n.id === flow.editNodeId);
|
|
719
|
+
if (!node) return null;
|
|
720
|
+
node.message = collectMessageFromDom($("flowMsgBody"));
|
|
721
|
+
return node;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function renderFlowMessage(node, hit) {
|
|
725
|
+
const el = $("flowMsgBody");
|
|
726
|
+
if (!el) return;
|
|
727
|
+
const fields = hit?.rpc?.requestFields || [];
|
|
728
|
+
if (!fields.length) {
|
|
729
|
+
el.innerHTML = `<div class="flow-msg-empty">无请求字段</div>`;
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
if (!node.message || typeof node.message !== "object" || Array.isArray(node.message)) {
|
|
733
|
+
node.message = {};
|
|
734
|
+
}
|
|
735
|
+
el.innerHTML = renderFields(fields, node.message);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function renderExtractFields(fields, extracts, path = [], depth = 0) {
|
|
739
|
+
if (!fields?.length) return `<div class="flow-msg-empty">无响应字段</div>`;
|
|
740
|
+
if (depth > 8) return `<div class="flow-msg-empty">嵌套过深,已截断</div>`;
|
|
741
|
+
return fields.map((field) => {
|
|
742
|
+
const nextPath = [...path, field.name];
|
|
743
|
+
const key = dottedPath(nextPath);
|
|
744
|
+
const typeText = typeLabel(field);
|
|
745
|
+
|
|
746
|
+
// message(非 repeated)→ 展开子字段,便于填 TokenInfo.AccessToken
|
|
747
|
+
if (!field.repeated && !field.key && isMessageField(field)) {
|
|
748
|
+
const typeDef = fieldTypeDef(field);
|
|
749
|
+
return `<div class="flow-extract-msg">
|
|
750
|
+
<div class="flow-extract-msg-head">
|
|
751
|
+
<strong>${esc(field.name)}</strong>
|
|
752
|
+
<span>${esc(typeText)}</span>
|
|
753
|
+
${field.comment ? `<span class="flow-msg-comment">${esc(field.comment)}</span>` : ""}
|
|
754
|
+
</div>
|
|
755
|
+
<div class="flow-extract-children">${renderExtractFields(typeDef?.fields || [], extracts, nextPath, depth + 1)}</div>
|
|
756
|
+
</div>`;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
return `<div class="flow-extract-row">
|
|
760
|
+
<div>
|
|
761
|
+
<div class="flow-extract-path">${esc(key)}</div>
|
|
762
|
+
<span class="flow-extract-type">${esc(typeText)}${field.comment ? ` · ${esc(field.comment)}` : ""}</span>
|
|
763
|
+
</div>
|
|
764
|
+
<input data-flow-extract-path="${esc(key)}" value="${esc(extracts?.[key] || "")}" placeholder="变量名,如 token" />
|
|
765
|
+
</div>`;
|
|
766
|
+
}).join("");
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function renderFlowExtract(node, hit) {
|
|
770
|
+
const el = $("flowExtractBody");
|
|
771
|
+
if (!el) return;
|
|
772
|
+
if (!node.extracts || typeof node.extracts !== "object") node.extracts = {};
|
|
773
|
+
const fields = hit?.rpc?.responseFields || [];
|
|
774
|
+
el.innerHTML = fields.length
|
|
775
|
+
? renderExtractFields(fields, node.extracts)
|
|
776
|
+
: `<div class="flow-msg-empty">无响应字段</div>`;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function normalizeNodeProtocol(value) {
|
|
780
|
+
const p = (value || "grpc").toLowerCase();
|
|
781
|
+
if (p === "http" || p === "sse" || p === "websocket") return p;
|
|
782
|
+
return "grpc";
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function protocolLabel(node) {
|
|
786
|
+
const p = normalizeNodeProtocol(node.protocol);
|
|
787
|
+
if (p === "http") return `HTTP ${node.httpMethod || "POST"}`;
|
|
788
|
+
if (p === "sse") return `SSE ${node.httpMethod || "GET"}`;
|
|
789
|
+
if (p === "websocket") return "WebSocket";
|
|
790
|
+
return "gRPC";
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function renderNodeTransport(node, hit) {
|
|
794
|
+
const protocol = normalizeNodeProtocol(node.protocol);
|
|
795
|
+
const protoEl = $("flowNodeProtocol");
|
|
796
|
+
const methodEl = $("flowNodeHttpMethod");
|
|
797
|
+
const pathEl = $("flowNodeHttpPath");
|
|
798
|
+
const envEl = $("flowNodeEnv");
|
|
799
|
+
if (protoEl) protoEl.value = protocol;
|
|
800
|
+
if (methodEl) {
|
|
801
|
+
methodEl.value = node.httpMethod
|
|
802
|
+
|| (protocol === "sse" ? "GET" : hit?.rpc.httpVerb || "POST");
|
|
803
|
+
}
|
|
804
|
+
if (pathEl) pathEl.value = node.httpPath || hit?.rpc.httpPath || "/";
|
|
805
|
+
if (envEl) {
|
|
806
|
+
const envs = api()?.listEnvs?.() || [];
|
|
807
|
+
const selected = node.envId || api()?.getActiveEnvId?.() || "";
|
|
808
|
+
envEl.innerHTML = envs.map((env) => `<option value="${esc(env.id)}"${env.id === selected ? " selected" : ""}>${esc(env.name)}</option>`).join("")
|
|
809
|
+
|| `<option value="">未配置环境</option>`;
|
|
810
|
+
if (selected) envEl.value = selected;
|
|
811
|
+
}
|
|
812
|
+
syncNodeTransportUi();
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function syncNodeTransportUi() {
|
|
816
|
+
const protocol = normalizeNodeProtocol($("flowNodeProtocol")?.value);
|
|
817
|
+
const needsMethod = protocol === "http" || protocol === "sse";
|
|
818
|
+
const needsPath = protocol === "http" || protocol === "sse" || protocol === "websocket";
|
|
819
|
+
$("flowNodeMethodWrap")?.classList.toggle("hidden", !needsMethod);
|
|
820
|
+
$("flowNodePathWrap")?.classList.toggle("hidden", !needsPath);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function openEdit(nodeId) {
|
|
824
|
+
const node = flow.nodes.find((n) => n.id === nodeId);
|
|
825
|
+
const hit = node ? api()?.findRpc?.(node.rpcId) : null;
|
|
826
|
+
if (!node || !hit) return;
|
|
827
|
+
flow.editNodeId = nodeId;
|
|
828
|
+
$("flowEditTitle").textContent = `编辑 · ${hit.rpc.name}`;
|
|
829
|
+
renderNodeTransport(node, hit);
|
|
830
|
+
renderFlowMessage(node, hit);
|
|
831
|
+
renderFlowMeta(node);
|
|
832
|
+
renderFlowExtract(node, hit);
|
|
833
|
+
$("flowEditModal").classList.add("show");
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function openHistory(nodeId) {
|
|
837
|
+
const node = flow.nodes.find((n) => n.id === nodeId);
|
|
838
|
+
if (!node) return;
|
|
839
|
+
flow.historyNodeId = nodeId;
|
|
840
|
+
flow.historySelectedId = node.history?.[0]?.id || null;
|
|
841
|
+
$("flowHistoryTitle").textContent = `响应历史 · ${node.name || "节点"}`;
|
|
842
|
+
renderHistoryModal();
|
|
843
|
+
$("flowHistoryModal")?.classList.add("show");
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function renderHistoryModal() {
|
|
847
|
+
const node = flow.nodes.find((n) => n.id === flow.historyNodeId);
|
|
848
|
+
const list = $("flowHistoryList");
|
|
849
|
+
const detail = $("flowHistoryDetail");
|
|
850
|
+
if (!list || !detail) return;
|
|
851
|
+
const items = Array.isArray(node?.history) ? node.history : [];
|
|
852
|
+
if (!items.length) {
|
|
853
|
+
list.innerHTML = `<div class="flow-msg-empty" style="padding:12px">暂无运行/压测记录</div>`;
|
|
854
|
+
detail.textContent = "运行链路或压测后,可在此查看该节点的响应历史。";
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
if (!flow.historySelectedId || !items.some((item) => item.id === flow.historySelectedId)) {
|
|
858
|
+
flow.historySelectedId = items[0].id;
|
|
859
|
+
}
|
|
860
|
+
list.innerHTML = items.map((item) => {
|
|
861
|
+
const okClass = item.ok ? "ok" : "fail";
|
|
862
|
+
const kind = item.kind === "stress" ? "压测" : "运行";
|
|
863
|
+
return `<button type="button" class="flow-history-item${item.id === flow.historySelectedId ? " active" : ""}" data-history-id="${esc(item.id)}">
|
|
864
|
+
<div class="h-top ${okClass}"><span>${esc(item.status || (item.ok ? "OK" : "FAIL"))}</span><span>${item.ms ?? "-"}ms</span></div>
|
|
865
|
+
<div class="h-meta">${esc(kind)} · ${esc(formatHistoryTime(item.at))}</div>
|
|
866
|
+
</button>`;
|
|
867
|
+
}).join("");
|
|
868
|
+
const selected = items.find((item) => item.id === flow.historySelectedId) || items[0];
|
|
869
|
+
const payload = {
|
|
870
|
+
ok: selected.ok,
|
|
871
|
+
status: selected.status,
|
|
872
|
+
ms: selected.ms,
|
|
873
|
+
error: selected.error || undefined,
|
|
874
|
+
kind: selected.kind,
|
|
875
|
+
at: formatHistoryTime(selected.at),
|
|
876
|
+
body: selected.body ?? null,
|
|
877
|
+
};
|
|
878
|
+
detail.textContent = JSON.stringify(payload, null, 2);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function renderFlowMeta(node) {
|
|
882
|
+
if (!node.metadata?.length) node.metadata = [{ name: "", value: "" }];
|
|
883
|
+
$("flowMetaBody").innerHTML = node.metadata.map((row, index) => `<tr>
|
|
884
|
+
<td><input data-flow-meta-name="${index}" value="${esc(row.name)}" placeholder="authorization" /></td>
|
|
885
|
+
<td><input data-flow-meta-value="${index}" value="${esc(row.value)}" placeholder="Bearer {{token}}" /></td>
|
|
886
|
+
<td><button class="link danger" type="button" data-flow-meta-del="${index}">删除</button></td>
|
|
887
|
+
</tr>`).join("");
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function saveEdit() {
|
|
891
|
+
const node = syncEditMessage();
|
|
892
|
+
if (!node) return;
|
|
893
|
+
const metadata = [];
|
|
894
|
+
for (const input of document.querySelectorAll("[data-flow-meta-name]")) {
|
|
895
|
+
const index = Number(input.getAttribute("data-flow-meta-name"));
|
|
896
|
+
const value = document.querySelector(`[data-flow-meta-value="${index}"]`)?.value || "";
|
|
897
|
+
metadata.push({ name: input.value, value });
|
|
898
|
+
}
|
|
899
|
+
node.metadata = metadata.length ? metadata : [{ name: "", value: "" }];
|
|
900
|
+
const extracts = {};
|
|
901
|
+
for (const input of document.querySelectorAll("[data-flow-extract-path]")) {
|
|
902
|
+
const key = input.getAttribute("data-flow-extract-path");
|
|
903
|
+
const val = input.value.trim();
|
|
904
|
+
if (key && val) extracts[key] = val;
|
|
905
|
+
}
|
|
906
|
+
node.extracts = extracts;
|
|
907
|
+
node.protocol = normalizeNodeProtocol($("flowNodeProtocol")?.value);
|
|
908
|
+
node.httpMethod = $("flowNodeHttpMethod")?.value || (node.protocol === "sse" ? "GET" : "POST");
|
|
909
|
+
node.httpPath = $("flowNodeHttpPath")?.value || "/";
|
|
910
|
+
node.envId = $("flowNodeEnv")?.value || node.envId || "";
|
|
911
|
+
$("flowEditModal").classList.remove("show");
|
|
912
|
+
flow.editNodeId = null;
|
|
913
|
+
save();
|
|
914
|
+
render();
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
function handleMsgTreeClick(event) {
|
|
918
|
+
const addBtn = event.target.closest("[data-flow-rep-add]");
|
|
919
|
+
const delBtn = event.target.closest("[data-flow-rep-del]");
|
|
920
|
+
if (!addBtn && !delBtn) return;
|
|
921
|
+
const node = syncEditMessage();
|
|
922
|
+
const hit = node ? api()?.findRpc?.(node.rpcId) : null;
|
|
923
|
+
if (!node || !hit) return;
|
|
924
|
+
|
|
925
|
+
if (addBtn) {
|
|
926
|
+
const path = parsePath(addBtn.getAttribute("data-flow-rep-add"));
|
|
927
|
+
if (!path) return;
|
|
928
|
+
const field = fieldAtPath(hit.rpc.requestFields || [], path);
|
|
929
|
+
let list = getByPath(node.message, path);
|
|
930
|
+
if (!Array.isArray(list)) {
|
|
931
|
+
list = [];
|
|
932
|
+
setByPath(node.message, path, list);
|
|
933
|
+
}
|
|
934
|
+
list.push(field ? sampleItem(field) : "");
|
|
935
|
+
renderFlowMessage(node, hit);
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
if (delBtn) {
|
|
940
|
+
const path = parsePath(delBtn.getAttribute("data-flow-rep-del"));
|
|
941
|
+
const index = Number(delBtn.getAttribute("data-flow-rep-index"));
|
|
942
|
+
if (!path || !Number.isFinite(index)) return;
|
|
943
|
+
const list = getByPath(node.message, path);
|
|
944
|
+
if (!Array.isArray(list)) return;
|
|
945
|
+
list.splice(index, 1);
|
|
946
|
+
renderFlowMessage(node, hit);
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/** path points to a repeated/map field (ends with field name, not index) */
|
|
951
|
+
function fieldAtPath(fields, path) {
|
|
952
|
+
let currentFields = fields;
|
|
953
|
+
let field = null;
|
|
954
|
+
for (let i = 0; i < path.length; i += 1) {
|
|
955
|
+
const key = path[i];
|
|
956
|
+
if (typeof key === "number") {
|
|
957
|
+
const typeDef = field ? fieldTypeDef(field) : null;
|
|
958
|
+
currentFields = typeDef?.kind === "message" ? (typeDef.fields || []) : [];
|
|
959
|
+
continue;
|
|
960
|
+
}
|
|
961
|
+
field = (currentFields || []).find((f) => f.name === key) || null;
|
|
962
|
+
if (!field) return null;
|
|
963
|
+
if (i < path.length - 1 && typeof path[i + 1] === "string") {
|
|
964
|
+
const typeDef = fieldTypeDef(field);
|
|
965
|
+
currentFields = typeDef?.kind === "message" ? (typeDef.fields || []) : [];
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
return field;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
function interpolate(text, vars) {
|
|
972
|
+
return String(text ?? "").replace(/\{\{\s*([\w.-]+)\s*\}\}/g, (_, key) => {
|
|
973
|
+
const value = vars[key];
|
|
974
|
+
return value === undefined || value === null ? "" : String(value);
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function coerceScalar(filled) {
|
|
979
|
+
if (filled.includes("{{")) return filled;
|
|
980
|
+
if (/^-?\d+(\.\d+)?$/.test(filled)) return Number(filled);
|
|
981
|
+
if (filled === "true" || filled === "false") return filled === "true";
|
|
982
|
+
return filled;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
function buildBody(value, vars) {
|
|
986
|
+
if (typeof value === "string") return coerceScalar(interpolate(value, vars));
|
|
987
|
+
if (Array.isArray(value)) return value.map((item) => buildBody(item, vars));
|
|
988
|
+
if (value && typeof value === "object") {
|
|
989
|
+
const out = {};
|
|
990
|
+
for (const [key, child] of Object.entries(value)) {
|
|
991
|
+
out[key] = buildBody(child, vars);
|
|
992
|
+
}
|
|
993
|
+
return out;
|
|
994
|
+
}
|
|
995
|
+
return value;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
function topoSort() {
|
|
999
|
+
const indeg = new Map(flow.nodes.map((n) => [n.id, 0]));
|
|
1000
|
+
const outs = new Map(flow.nodes.map((n) => [n.id, []]));
|
|
1001
|
+
for (const edge of flow.edges) {
|
|
1002
|
+
if (!indeg.has(edge.from) || !indeg.has(edge.to)) continue;
|
|
1003
|
+
indeg.set(edge.to, (indeg.get(edge.to) || 0) + 1);
|
|
1004
|
+
outs.get(edge.from).push(edge.to);
|
|
1005
|
+
}
|
|
1006
|
+
const queue = [...indeg.entries()].filter(([, d]) => d === 0).map(([id]) => id);
|
|
1007
|
+
const order = [];
|
|
1008
|
+
while (queue.length) {
|
|
1009
|
+
const id = queue.shift();
|
|
1010
|
+
order.push(id);
|
|
1011
|
+
for (const next of outs.get(id) || []) {
|
|
1012
|
+
indeg.set(next, indeg.get(next) - 1);
|
|
1013
|
+
if (indeg.get(next) === 0) queue.push(next);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
if (order.length !== flow.nodes.length) return null;
|
|
1017
|
+
return order;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
async function executeFlowOnce({ quiet = false, kind = "run" } = {}) {
|
|
1021
|
+
const client = api();
|
|
1022
|
+
if (!client?.invokeOnce) {
|
|
1023
|
+
return { ok: false, ms: 0, error: "请在 VS Code ProtoView Client 中运行" };
|
|
1024
|
+
}
|
|
1025
|
+
if (!flow.nodes.length) {
|
|
1026
|
+
return { ok: false, ms: 0, error: "画布为空,请先添加接口" };
|
|
1027
|
+
}
|
|
1028
|
+
const order = topoSort();
|
|
1029
|
+
if (!order) {
|
|
1030
|
+
return { ok: false, ms: 0, error: "存在环路,请先去掉循环连线" };
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
if (!quiet) {
|
|
1034
|
+
for (const edge of flow.edges) edge.status = "idle";
|
|
1035
|
+
for (const node of flow.nodes) node.result = null;
|
|
1036
|
+
render();
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
const started = performance.now();
|
|
1040
|
+
const vars = {};
|
|
1041
|
+
const byId = new Map(flow.nodes.map((n) => [n.id, n]));
|
|
1042
|
+
for (const nodeId of order) {
|
|
1043
|
+
if (flow.stressAbort) {
|
|
1044
|
+
return { ok: false, ms: Math.round(performance.now() - started), error: "已停止", cancelled: true };
|
|
1045
|
+
}
|
|
1046
|
+
const node = byId.get(nodeId);
|
|
1047
|
+
const hit = client.findRpc(node.rpcId);
|
|
1048
|
+
if (!hit) continue;
|
|
1049
|
+
const incoming = flow.edges.filter((e) => e.to === nodeId);
|
|
1050
|
+
const metadata = {};
|
|
1051
|
+
for (const row of node.metadata || []) {
|
|
1052
|
+
const name = (row.name || "").trim();
|
|
1053
|
+
if (!name) continue;
|
|
1054
|
+
metadata[name] = interpolate(row.value, vars);
|
|
1055
|
+
}
|
|
1056
|
+
const protocol = normalizeNodeProtocol(node.protocol);
|
|
1057
|
+
const result = await client.invokeOnce({
|
|
1058
|
+
protocol,
|
|
1059
|
+
httpMethod: node.httpMethod || (protocol === "sse" ? "GET" : hit.rpc.httpVerb || "POST"),
|
|
1060
|
+
httpPath: interpolate(node.httpPath || hit.rpc.httpPath || "/", vars),
|
|
1061
|
+
host: client.resolveHost?.(node.envId) || "",
|
|
1062
|
+
package: hit.pkg.name,
|
|
1063
|
+
service: hit.service.name,
|
|
1064
|
+
method: hit.rpc.name,
|
|
1065
|
+
file: hit.rpc.file,
|
|
1066
|
+
body: buildBody(node.message, vars),
|
|
1067
|
+
metadata,
|
|
1068
|
+
tls: false,
|
|
1069
|
+
});
|
|
1070
|
+
node.result = {
|
|
1071
|
+
ok: result.ok,
|
|
1072
|
+
status: result.status,
|
|
1073
|
+
ms: result.ms,
|
|
1074
|
+
error: result.error,
|
|
1075
|
+
body: result.body,
|
|
1076
|
+
};
|
|
1077
|
+
pushHistory(node, {
|
|
1078
|
+
kind,
|
|
1079
|
+
ok: result.ok,
|
|
1080
|
+
status: result.status,
|
|
1081
|
+
ms: result.ms,
|
|
1082
|
+
error: result.error,
|
|
1083
|
+
body: result.body ?? null,
|
|
1084
|
+
});
|
|
1085
|
+
for (const edge of incoming) edge.status = result.ok ? "ok" : "fail";
|
|
1086
|
+
if (result.ok && result.body && typeof result.body === "object") {
|
|
1087
|
+
for (const [pathKey, varName] of Object.entries(node.extracts || {})) {
|
|
1088
|
+
if (!varName) continue;
|
|
1089
|
+
const val = valueAtDotted(result.body, pathKey);
|
|
1090
|
+
if (val !== undefined) vars[varName] = val;
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
if (!quiet) render();
|
|
1094
|
+
if (!result.ok) {
|
|
1095
|
+
if (!quiet) {
|
|
1096
|
+
for (const edge of flow.edges.filter((e) => e.from === nodeId)) edge.status = "fail";
|
|
1097
|
+
render();
|
|
1098
|
+
}
|
|
1099
|
+
return {
|
|
1100
|
+
ok: false,
|
|
1101
|
+
ms: Math.round(performance.now() - started),
|
|
1102
|
+
error: result.error || result.status || "调用失败",
|
|
1103
|
+
failNode: node.name || hit.rpc.name,
|
|
1104
|
+
};
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
return { ok: true, ms: Math.round(performance.now() - started) };
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
async function runFlow() {
|
|
1111
|
+
if (flow.running || flow.stressing) return;
|
|
1112
|
+
const client = api();
|
|
1113
|
+
if (!client?.invokeOnce) {
|
|
1114
|
+
void notify("请在 VS Code ProtoView Client 中运行链路调试");
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
flow.running = true;
|
|
1118
|
+
flow.stressAbort = false;
|
|
1119
|
+
$("flowRunBtn").disabled = true;
|
|
1120
|
+
$("flowStressBtn").disabled = true;
|
|
1121
|
+
const summary = await executeFlowOnce({ quiet: false });
|
|
1122
|
+
if (!summary.ok && summary.error && !summary.failNode) void notify(summary.error);
|
|
1123
|
+
flow.running = false;
|
|
1124
|
+
$("flowRunBtn").disabled = false;
|
|
1125
|
+
$("flowStressBtn").disabled = false;
|
|
1126
|
+
save();
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function openStressDrawer() {
|
|
1130
|
+
const mask = $("flowStressDrawer");
|
|
1131
|
+
if (!mask) return;
|
|
1132
|
+
mask.classList.add("show");
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
function closeStressDrawer() {
|
|
1136
|
+
const mask = $("flowStressDrawer");
|
|
1137
|
+
if (!mask) return;
|
|
1138
|
+
mask.classList.remove("show");
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
function setStressUiRunning(running) {
|
|
1142
|
+
flow.stressing = running;
|
|
1143
|
+
$("flowStressStartBtn") && ($("flowStressStartBtn").disabled = running);
|
|
1144
|
+
$("flowStressStopBtn") && ($("flowStressStopBtn").disabled = !running);
|
|
1145
|
+
$("flowRunBtn") && ($("flowRunBtn").disabled = running);
|
|
1146
|
+
$("flowStressBtn") && ($("flowStressBtn").disabled = running);
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
function updateStressProgress(done, total, ok, fail) {
|
|
1150
|
+
const pct = total ? Math.min(100, Math.round((done / total) * 100)) : 0;
|
|
1151
|
+
const bar = $("flowStressProgressBar");
|
|
1152
|
+
if (bar) bar.style.width = `${pct}%`;
|
|
1153
|
+
const text = $("flowStressProgressText");
|
|
1154
|
+
if (text) text.textContent = `${done}/${total} · 成功 ${ok} · 失败 ${fail}`;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
async function runFlowStress() {
|
|
1158
|
+
if (flow.running || flow.stressing) return;
|
|
1159
|
+
if (!flow.nodes.length) {
|
|
1160
|
+
void notify("画布为空,请先添加接口");
|
|
1161
|
+
return;
|
|
1162
|
+
}
|
|
1163
|
+
if (!topoSort()) {
|
|
1164
|
+
void notify("存在环路,请先去掉循环连线");
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
const concurrency = Math.min(5, Math.max(1, Number($("flowStressConcurrency")?.value || 1)));
|
|
1168
|
+
const total = Math.min(100, Math.max(1, Number($("flowStressTotal")?.value || 1)));
|
|
1169
|
+
if ($("flowStressConcurrency")) $("flowStressConcurrency").value = String(concurrency);
|
|
1170
|
+
if ($("flowStressTotal")) $("flowStressTotal").value = String(total);
|
|
1171
|
+
|
|
1172
|
+
flow.stressAbort = false;
|
|
1173
|
+
setStressUiRunning(true);
|
|
1174
|
+
updateStressProgress(0, total, 0, 0);
|
|
1175
|
+
const resultEl = $("flowStressResult");
|
|
1176
|
+
if (resultEl) resultEl.innerHTML = `<div class="stress-empty">压测进行中…</div>`;
|
|
1177
|
+
|
|
1178
|
+
let next = 0;
|
|
1179
|
+
let done = 0;
|
|
1180
|
+
let ok = 0;
|
|
1181
|
+
let fail = 0;
|
|
1182
|
+
const samples = [];
|
|
1183
|
+
const started = Date.now();
|
|
1184
|
+
|
|
1185
|
+
async function worker() {
|
|
1186
|
+
while (!flow.stressAbort) {
|
|
1187
|
+
const index = next;
|
|
1188
|
+
next += 1;
|
|
1189
|
+
if (index >= total) break;
|
|
1190
|
+
const one = await executeFlowOnce({ quiet: true, kind: "stress" });
|
|
1191
|
+
done += 1;
|
|
1192
|
+
if (one.ok) ok += 1;
|
|
1193
|
+
else fail += 1;
|
|
1194
|
+
if (samples.length < 50) {
|
|
1195
|
+
samples.push({
|
|
1196
|
+
i: index + 1,
|
|
1197
|
+
ok: one.ok,
|
|
1198
|
+
ms: one.ms,
|
|
1199
|
+
status: one.ok ? "OK" : (one.error || "FAIL"),
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
updateStressProgress(done, total, ok, fail);
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
await Promise.all(Array.from({ length: concurrency }, () => worker()));
|
|
1207
|
+
render();
|
|
1208
|
+
save();
|
|
1209
|
+
|
|
1210
|
+
const elapsed = Math.max(1, Date.now() - started);
|
|
1211
|
+
const rps = (done / (elapsed / 1000)).toFixed(2);
|
|
1212
|
+
const cancelled = flow.stressAbort;
|
|
1213
|
+
setStressUiRunning(false);
|
|
1214
|
+
flow.stressAbort = false;
|
|
1215
|
+
|
|
1216
|
+
if (resultEl) {
|
|
1217
|
+
const stats = [
|
|
1218
|
+
["完成", done],
|
|
1219
|
+
["成功", ok],
|
|
1220
|
+
["失败", fail],
|
|
1221
|
+
["RPS", rps],
|
|
1222
|
+
["耗时", `${elapsed}ms`],
|
|
1223
|
+
].map(([label, value]) => `<div class="stress-stat"><b>${esc(String(value))}</b><span>${esc(label)}</span></div>`).join("");
|
|
1224
|
+
const rows = samples.map((item) => `<tr>
|
|
1225
|
+
<td>#${item.i}</td>
|
|
1226
|
+
<td class="${item.ok ? "stress-ok" : "stress-fail"}">${esc(item.status)}</td>
|
|
1227
|
+
<td>${item.ms}ms</td>
|
|
1228
|
+
</tr>`).join("");
|
|
1229
|
+
resultEl.innerHTML = `
|
|
1230
|
+
<div class="stress-stats">${stats}</div>
|
|
1231
|
+
${cancelled ? `<p class="stress-note">已停止,以下为已完成样本。</p>` : ""}
|
|
1232
|
+
<table class="stress-samples">
|
|
1233
|
+
<thead><tr><th>序号</th><th>状态</th><th>耗时</th></tr></thead>
|
|
1234
|
+
<tbody>${rows || `<tr><td colspan="3">无样本</td></tr>`}</tbody>
|
|
1235
|
+
</table>
|
|
1236
|
+
`;
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
function screenToWorld(clientX, clientY) {
|
|
1241
|
+
const el = $("flowStage");
|
|
1242
|
+
if (!el) return { x: 80, y: 80 };
|
|
1243
|
+
const stage = el.getBoundingClientRect();
|
|
1244
|
+
return {
|
|
1245
|
+
x: (clientX - stage.left - flow.panX) / flow.scale,
|
|
1246
|
+
y: (clientY - stage.top - flow.panY) / flow.scale,
|
|
1247
|
+
};
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
function addAtScreen(rpcId, clientX, clientY, label) {
|
|
1251
|
+
if (!rpcId) return false;
|
|
1252
|
+
if (!isFlowOpen()) openFlow();
|
|
1253
|
+
// Defer so #flowStage has real size after openFlow layout
|
|
1254
|
+
requestAnimationFrame(() => {
|
|
1255
|
+
const pos = screenToWorld(clientX, clientY);
|
|
1256
|
+
addNode(rpcId, pos.x - 100, pos.y - 36, label);
|
|
1257
|
+
});
|
|
1258
|
+
return true;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
// Expose early so app.js drag-drop can call even if later bind steps fail
|
|
1262
|
+
window.ProtoViewFlow = { addNode, addSelected, openFlow, isFlowOpen, addAtScreen, render, importData };
|
|
1263
|
+
|
|
1264
|
+
function bind() {
|
|
1265
|
+
load();
|
|
1266
|
+
hideMenu();
|
|
1267
|
+
$("flowBtn")?.addEventListener("click", openFlow);
|
|
1268
|
+
$("flowCloseBtn")?.addEventListener("click", closeFlow);
|
|
1269
|
+
$("flowAddSelectedBtn")?.addEventListener("click", addSelected);
|
|
1270
|
+
$("flowClearBtn")?.addEventListener("click", clearCanvas);
|
|
1271
|
+
$("flowRunBtn")?.addEventListener("click", () => void runFlow());
|
|
1272
|
+
$("flowStressBtn")?.addEventListener("click", openStressDrawer);
|
|
1273
|
+
$("flowExportBtn")?.addEventListener("click", exportFlow);
|
|
1274
|
+
$("flowImportBtn")?.addEventListener("click", requestImportFlow);
|
|
1275
|
+
$("flowStressClose")?.addEventListener("click", closeStressDrawer);
|
|
1276
|
+
$("flowStressDrawer")?.addEventListener("click", (event) => {
|
|
1277
|
+
if (event.target === $("flowStressDrawer")) closeStressDrawer();
|
|
1278
|
+
});
|
|
1279
|
+
$("flowStressStartBtn")?.addEventListener("click", () => void runFlowStress());
|
|
1280
|
+
$("flowStressStopBtn")?.addEventListener("click", () => { flow.stressAbort = true; });
|
|
1281
|
+
$("flowStressConcurrency")?.addEventListener("change", () => {
|
|
1282
|
+
const n = Math.min(5, Math.max(1, Number($("flowStressConcurrency").value || 1)));
|
|
1283
|
+
$("flowStressConcurrency").value = String(n);
|
|
1284
|
+
});
|
|
1285
|
+
$("flowStressTotal")?.addEventListener("change", () => {
|
|
1286
|
+
const n = Math.min(100, Math.max(1, Number($("flowStressTotal").value || 1)));
|
|
1287
|
+
$("flowStressTotal").value = String(n);
|
|
1288
|
+
});
|
|
1289
|
+
window.addEventListener("protoview-flow-add", (event) => {
|
|
1290
|
+
const { rpcId, label, clientX, clientY } = event.detail || {};
|
|
1291
|
+
if (!rpcId) return;
|
|
1292
|
+
if (!isFlowOpen()) openFlow();
|
|
1293
|
+
if (Number.isFinite(clientX) && Number.isFinite(clientY)) {
|
|
1294
|
+
addAtScreen(rpcId, clientX, clientY, label);
|
|
1295
|
+
} else {
|
|
1296
|
+
addNode(rpcId, undefined, undefined, label);
|
|
1297
|
+
}
|
|
1298
|
+
});
|
|
1299
|
+
$("closeFlowEdit")?.addEventListener("click", () => $("flowEditModal").classList.remove("show"));
|
|
1300
|
+
$("saveFlowEditBtn")?.addEventListener("click", saveEdit);
|
|
1301
|
+
$("flowNodeProtocol")?.addEventListener("change", syncNodeTransportUi);
|
|
1302
|
+
$("flowMsgBody")?.addEventListener("click", handleMsgTreeClick);
|
|
1303
|
+
$("flowAddMetaBtn")?.addEventListener("click", () => {
|
|
1304
|
+
const node = flow.nodes.find((n) => n.id === flow.editNodeId);
|
|
1305
|
+
if (!node) return;
|
|
1306
|
+
node.metadata = node.metadata || [];
|
|
1307
|
+
node.metadata.push({ name: "", value: "" });
|
|
1308
|
+
renderFlowMeta(node);
|
|
1309
|
+
});
|
|
1310
|
+
$("flowMetaBody")?.addEventListener("click", (event) => {
|
|
1311
|
+
const del = event.target.closest("[data-flow-meta-del]");
|
|
1312
|
+
if (!del) return;
|
|
1313
|
+
const node = flow.nodes.find((n) => n.id === flow.editNodeId);
|
|
1314
|
+
if (!node) return;
|
|
1315
|
+
node.metadata.splice(Number(del.getAttribute("data-flow-meta-del")), 1);
|
|
1316
|
+
renderFlowMeta(node);
|
|
1317
|
+
});
|
|
1318
|
+
|
|
1319
|
+
$("flowMenu")?.addEventListener("click", (event) => {
|
|
1320
|
+
const act = event.target.closest("[data-flow-act]")?.getAttribute("data-flow-act");
|
|
1321
|
+
const nodeId = flow.menuNodeId;
|
|
1322
|
+
hideMenu();
|
|
1323
|
+
if (!nodeId || !act) return;
|
|
1324
|
+
if (act === "edit") openEdit(nodeId);
|
|
1325
|
+
if (act === "view-resp") openHistory(nodeId);
|
|
1326
|
+
if (act === "delete") {
|
|
1327
|
+
flow.nodes = flow.nodes.filter((n) => n.id !== nodeId);
|
|
1328
|
+
flow.edges = flow.edges.filter((e) => e.from !== nodeId && e.to !== nodeId);
|
|
1329
|
+
save();
|
|
1330
|
+
render();
|
|
1331
|
+
}
|
|
1332
|
+
});
|
|
1333
|
+
$("closeFlowHistory")?.addEventListener("click", () => {
|
|
1334
|
+
$("flowHistoryModal")?.classList.remove("show");
|
|
1335
|
+
flow.historyNodeId = null;
|
|
1336
|
+
flow.historySelectedId = null;
|
|
1337
|
+
});
|
|
1338
|
+
$("flowHistoryList")?.addEventListener("click", (event) => {
|
|
1339
|
+
const btn = event.target.closest("[data-history-id]");
|
|
1340
|
+
if (!btn) return;
|
|
1341
|
+
flow.historySelectedId = btn.getAttribute("data-history-id");
|
|
1342
|
+
renderHistoryModal();
|
|
1343
|
+
});
|
|
1344
|
+
|
|
1345
|
+
const stage = $("flowStage");
|
|
1346
|
+
if (!stage) {
|
|
1347
|
+
console.warn("ProtoView flow: #flowStage not found");
|
|
1348
|
+
return;
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
stage.addEventListener("wheel", (event) => {
|
|
1352
|
+
event.preventDefault();
|
|
1353
|
+
const delta = event.deltaY > 0 ? 0.9 : 1.1;
|
|
1354
|
+
const next = Math.min(2.2, Math.max(0.35, flow.scale * delta));
|
|
1355
|
+
const rect = stage.getBoundingClientRect();
|
|
1356
|
+
const mx = event.clientX - rect.left;
|
|
1357
|
+
const my = event.clientY - rect.top;
|
|
1358
|
+
flow.panX = mx - ((mx - flow.panX) / flow.scale) * next;
|
|
1359
|
+
flow.panY = my - ((my - flow.panY) / flow.scale) * next;
|
|
1360
|
+
flow.scale = next;
|
|
1361
|
+
applyTransform();
|
|
1362
|
+
save();
|
|
1363
|
+
}, { passive: false });
|
|
1364
|
+
|
|
1365
|
+
let panning = false;
|
|
1366
|
+
let panOrigin = null;
|
|
1367
|
+
stage.addEventListener("pointerdown", (event) => {
|
|
1368
|
+
const onBlank = event.target === stage || event.target.id === "flowEdges" || event.target.classList?.contains("flow-edges");
|
|
1369
|
+
if (event.button === 1 || event.altKey || onBlank) {
|
|
1370
|
+
panning = true;
|
|
1371
|
+
panOrigin = { x: event.clientX - flow.panX, y: event.clientY - flow.panY };
|
|
1372
|
+
stage.setPointerCapture(event.pointerId);
|
|
1373
|
+
}
|
|
1374
|
+
});
|
|
1375
|
+
stage.addEventListener("pointermove", (event) => {
|
|
1376
|
+
if (!panning || !panOrigin) return;
|
|
1377
|
+
flow.panX = event.clientX - panOrigin.x;
|
|
1378
|
+
flow.panY = event.clientY - panOrigin.y;
|
|
1379
|
+
applyTransform();
|
|
1380
|
+
});
|
|
1381
|
+
stage.addEventListener("pointerup", () => {
|
|
1382
|
+
if (panning) save();
|
|
1383
|
+
panning = false;
|
|
1384
|
+
panOrigin = null;
|
|
1385
|
+
});
|
|
1386
|
+
|
|
1387
|
+
stage.addEventListener("dragover", (event) => {
|
|
1388
|
+
event.preventDefault();
|
|
1389
|
+
event.dataTransfer.dropEffect = "copy";
|
|
1390
|
+
});
|
|
1391
|
+
stage.addEventListener("drop", (event) => {
|
|
1392
|
+
event.preventDefault();
|
|
1393
|
+
let rpcId = event.dataTransfer.getData("text/plain");
|
|
1394
|
+
let label = "";
|
|
1395
|
+
try {
|
|
1396
|
+
const packed = JSON.parse(event.dataTransfer.getData("application/x-protoview-rpc") || "null");
|
|
1397
|
+
if (packed?.id) {
|
|
1398
|
+
rpcId = packed.id;
|
|
1399
|
+
label = packed.label || "";
|
|
1400
|
+
}
|
|
1401
|
+
} catch { /* ignore */ }
|
|
1402
|
+
if (!rpcId) return;
|
|
1403
|
+
const pos = screenToWorld(event.clientX, event.clientY);
|
|
1404
|
+
addNode(rpcId, pos.x - 100, pos.y - 30, label);
|
|
1405
|
+
});
|
|
1406
|
+
|
|
1407
|
+
let dragNode = null;
|
|
1408
|
+
let dragOffset = null;
|
|
1409
|
+
let linkFrom = null;
|
|
1410
|
+
|
|
1411
|
+
stage.addEventListener("pointerdown", (event) => {
|
|
1412
|
+
const out = event.target.closest("[data-port-out]");
|
|
1413
|
+
if (out) {
|
|
1414
|
+
linkFrom = out.getAttribute("data-port-out");
|
|
1415
|
+
event.stopPropagation();
|
|
1416
|
+
return;
|
|
1417
|
+
}
|
|
1418
|
+
const head = event.target.closest("[data-drag]");
|
|
1419
|
+
if (head && event.button === 0) {
|
|
1420
|
+
const id = head.getAttribute("data-drag");
|
|
1421
|
+
const node = flow.nodes.find((n) => n.id === id);
|
|
1422
|
+
if (!node) return;
|
|
1423
|
+
dragNode = node;
|
|
1424
|
+
const pos = screenToWorld(event.clientX, event.clientY);
|
|
1425
|
+
dragOffset = { x: pos.x - node.x, y: pos.y - node.y };
|
|
1426
|
+
stage.setPointerCapture(event.pointerId);
|
|
1427
|
+
event.stopPropagation();
|
|
1428
|
+
}
|
|
1429
|
+
});
|
|
1430
|
+
stage.addEventListener("pointermove", (event) => {
|
|
1431
|
+
if (dragNode && dragOffset) {
|
|
1432
|
+
const pos = screenToWorld(event.clientX, event.clientY);
|
|
1433
|
+
dragNode.x = pos.x - dragOffset.x;
|
|
1434
|
+
dragNode.y = pos.y - dragOffset.y;
|
|
1435
|
+
render();
|
|
1436
|
+
}
|
|
1437
|
+
});
|
|
1438
|
+
stage.addEventListener("pointerup", (event) => {
|
|
1439
|
+
if (linkFrom) {
|
|
1440
|
+
const inn = document.elementFromPoint(event.clientX, event.clientY)?.closest?.("[data-port-in]");
|
|
1441
|
+
const to = inn?.getAttribute("data-port-in");
|
|
1442
|
+
if (to && to !== linkFrom && !flow.edges.some((e) => e.from === linkFrom && e.to === to)) {
|
|
1443
|
+
flow.edges.push({ id: uid("e"), from: linkFrom, to, status: "idle" });
|
|
1444
|
+
render();
|
|
1445
|
+
}
|
|
1446
|
+
linkFrom = null;
|
|
1447
|
+
}
|
|
1448
|
+
if (dragNode) {
|
|
1449
|
+
dragNode = null;
|
|
1450
|
+
dragOffset = null;
|
|
1451
|
+
save();
|
|
1452
|
+
}
|
|
1453
|
+
});
|
|
1454
|
+
|
|
1455
|
+
stage.addEventListener("contextmenu", (event) => {
|
|
1456
|
+
const nodeEl = event.target.closest("[data-node]");
|
|
1457
|
+
if (!nodeEl) {
|
|
1458
|
+
hideMenu();
|
|
1459
|
+
return;
|
|
1460
|
+
}
|
|
1461
|
+
event.preventDefault();
|
|
1462
|
+
event.stopPropagation();
|
|
1463
|
+
showMenu(event.clientX, event.clientY, nodeEl.getAttribute("data-node"));
|
|
1464
|
+
});
|
|
1465
|
+
document.addEventListener("click", (event) => {
|
|
1466
|
+
if (!event.target.closest("#flowMenu")) hideMenu();
|
|
1467
|
+
});
|
|
1468
|
+
|
|
1469
|
+
hideMenu();
|
|
1470
|
+
render();
|
|
1471
|
+
window.ProtoViewFlow = { addNode, addSelected, openFlow, isFlowOpen, addAtScreen, render, importData };
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
window.addEventListener("message", (event) => {
|
|
1475
|
+
const data = event.data;
|
|
1476
|
+
if (data?.type === "flowImportData") importData(data.payload);
|
|
1477
|
+
});
|
|
1478
|
+
|
|
1479
|
+
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", bind);
|
|
1480
|
+
else bind();
|
|
1481
|
+
})();
|