unreal-mcp-proxy 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.
- package/LICENSE +21 -0
- package/README.md +240 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +37 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +15 -0
- package/dist/config.js +51 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/proxy-server.d.ts +8 -0
- package/dist/proxy-server.js +333 -0
- package/dist/proxy-server.js.map +1 -0
- package/dist/redaction.d.ts +10 -0
- package/dist/redaction.js +55 -0
- package/dist/redaction.js.map +1 -0
- package/dist/session-model.d.ts +4 -0
- package/dist/session-model.js +270 -0
- package/dist/session-model.js.map +1 -0
- package/dist/session-store.d.ts +50 -0
- package/dist/session-store.js +111 -0
- package/dist/session-store.js.map +1 -0
- package/dist/sinks.d.ts +49 -0
- package/dist/sinks.js +42 -0
- package/dist/sinks.js.map +1 -0
- package/dist/stdio-bridge.d.ts +3 -0
- package/dist/stdio-bridge.js +137 -0
- package/dist/stdio-bridge.js.map +1 -0
- package/dist/types.d.ts +124 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/viewer.html +991 -0
- package/package.json +28 -0
- package/skills/unreal-mcp-observer/SKILL.md +88 -0
- package/skills/unreal-mcp-observer/scripts/query.mjs +225 -0
package/dist/viewer.html
ADDED
|
@@ -0,0 +1,991 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>unreal-mcp-proxy viewer</title>
|
|
7
|
+
<style>:root{
|
|
8
|
+
--bg:#0e0e13; --panel:#15151c; --panel2:#1b1b24;
|
|
9
|
+
--line:rgba(143,118,255,.16); --thread:rgba(230,228,240,.28);
|
|
10
|
+
--tx:#e6e4f0; --mut:#8b8a9c;
|
|
11
|
+
--acc:#8f76ff; --acc-hover:#a48fff; --acc2:rgba(143,118,255,.13);
|
|
12
|
+
--hi:#e6c35c;
|
|
13
|
+
--pass:#4cc38a; --warn:#e08a3d; --fail:#ff5c5c; --idle:#6a6a78;
|
|
14
|
+
--pass-bg:rgba(76,195,138,.13); --warn-bg:rgba(224,138,61,.13);
|
|
15
|
+
--fail-bg:rgba(255,92,92,.13); --idle-bg:rgba(106,106,120,.15);
|
|
16
|
+
--code:#0a0a0e; --hdr:44px;
|
|
17
|
+
--mono:ui-monospace,'Cascadia Mono',Consolas,monospace;
|
|
18
|
+
--sans:system-ui,sans-serif;
|
|
19
|
+
}
|
|
20
|
+
*{box-sizing:border-box;border-radius:0!important}
|
|
21
|
+
[hidden]{display:none!important}
|
|
22
|
+
html,body{margin:0;padding:0}
|
|
23
|
+
body{
|
|
24
|
+
background:var(--bg) repeating-linear-gradient(90deg, rgba(143,118,255,.05) 0 1px, transparent 1px 120px);
|
|
25
|
+
color:var(--tx); font:13px/1.5 var(--sans);
|
|
26
|
+
}
|
|
27
|
+
::selection{background:var(--hi);color:#0e0e13}
|
|
28
|
+
a{color:var(--acc);text-decoration:none}
|
|
29
|
+
|
|
30
|
+
header{
|
|
31
|
+
position:sticky;top:0;z-index:9;height:var(--hdr);
|
|
32
|
+
display:flex;align-items:center;gap:18px;padding:0 18px;
|
|
33
|
+
background:var(--panel);border-bottom:1px solid var(--line);
|
|
34
|
+
}
|
|
35
|
+
.brand{font:700 12px var(--mono);letter-spacing:.28em;color:var(--hi)}
|
|
36
|
+
.hdr-right{margin-left:auto;font:10.5px var(--mono);letter-spacing:.06em;color:var(--mut)}
|
|
37
|
+
|
|
38
|
+
main{max-width:1880px;margin:0 auto;padding:14px 20px 40px}
|
|
39
|
+
.cols{display:grid;grid-template-columns:minmax(0,1fr) clamp(480px,36vw,760px);gap:12px;align-items:start}
|
|
40
|
+
.col-side{position:sticky;top:calc(var(--hdr) + 12px);max-height:calc(100vh - var(--hdr) - 24px);overflow-y:auto}
|
|
41
|
+
.col-side .panel{margin-bottom:0}
|
|
42
|
+
@media (max-width:900px){.cols{grid-template-columns:1fr}.col-side{position:static;max-height:none}}
|
|
43
|
+
.panel{background:var(--panel);border:1px solid var(--line);padding:14px 16px;margin-bottom:12px}
|
|
44
|
+
|
|
45
|
+
.wlabel{position:relative;margin:0 0 10px;height:14px}
|
|
46
|
+
.wlabel::before{content:"";position:absolute;left:0;right:0;top:7px;height:1px;background:var(--thread)}
|
|
47
|
+
.wlabel span{position:relative;background:var(--panel);padding-right:8px;font:700 10.5px var(--mono);letter-spacing:.16em;color:var(--mut);text-transform:uppercase}
|
|
48
|
+
.wlabel .cnt{position:relative;float:right;background:var(--panel);padding-left:8px;font:10.5px var(--mono);color:var(--mut)}
|
|
49
|
+
|
|
50
|
+
.badge{display:inline-flex;align-items:center;gap:5px;font:700 10.5px var(--mono);letter-spacing:.04em;padding:2px 7px;text-transform:uppercase;white-space:nowrap}
|
|
51
|
+
.badge.pass{background:var(--pass-bg);color:var(--pass)}
|
|
52
|
+
.badge.warn{background:var(--warn-bg);color:var(--warn)}
|
|
53
|
+
.badge.fail{background:var(--fail-bg);color:var(--fail)}
|
|
54
|
+
.badge.run{background:var(--acc2);color:var(--acc)}
|
|
55
|
+
|
|
56
|
+
.rows{margin:0;padding:0;list-style:none;max-height:72vh;overflow-y:auto}
|
|
57
|
+
.rows li{display:flex;align-items:center;gap:10px;padding:6px 8px;cursor:pointer;border-bottom:1px solid var(--line);font:12.5px var(--mono)}
|
|
58
|
+
.rows li:hover{background:var(--panel2)}
|
|
59
|
+
.rows li.on{background:var(--acc);color:#0e0e13}
|
|
60
|
+
.rows li.on .mut{color:#0e0e13}
|
|
61
|
+
.rows .grow{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
62
|
+
.mut{color:var(--mut)}
|
|
63
|
+
.num{font-variant-numeric:tabular-nums}
|
|
64
|
+
|
|
65
|
+
.knot{width:8px;height:8px;flex:none;background:var(--idle)}
|
|
66
|
+
.knot.pass{background:var(--pass)}
|
|
67
|
+
.knot.fail{background:var(--fail)}
|
|
68
|
+
.knot.run{background:var(--acc);animation:knot-blink 1.2s infinite}
|
|
69
|
+
@keyframes knot-blink{0%,100%{opacity:1}50%{opacity:.35}}
|
|
70
|
+
|
|
71
|
+
.thread-line{position:relative;margin:4px 2px 8px;overflow:hidden}
|
|
72
|
+
.thread-line .track{position:relative;width:100%}
|
|
73
|
+
.thread-line .seg{position:absolute;height:1px;background:var(--thread)}
|
|
74
|
+
.thread-line .vseg{position:absolute;width:1px;background:var(--thread)}
|
|
75
|
+
.thread-line .tknot{position:absolute;cursor:pointer}
|
|
76
|
+
.thread-line .tknot.focus{outline:1px solid var(--hi);outline-offset:2px}
|
|
77
|
+
.thread-line .drawseg{position:absolute;height:1px;background:linear-gradient(90deg,var(--acc) 0 60%,transparent 60% 100%);background-size:14px 1px;animation:thread-draw 1.6s linear infinite}
|
|
78
|
+
@keyframes thread-draw{from{background-position:0 0}to{background-position:14px 0}}
|
|
79
|
+
|
|
80
|
+
.graph{overflow-x:auto}
|
|
81
|
+
.graph-bar{margin-bottom:8px}
|
|
82
|
+
.graph svg{display:block}
|
|
83
|
+
.gnode rect{fill:var(--panel2);stroke:var(--line);stroke-width:1}
|
|
84
|
+
.gnode.fail rect{stroke:var(--fail)}
|
|
85
|
+
.gnode.on rect{stroke:var(--hi);stroke-width:1.5}
|
|
86
|
+
.gnode{cursor:pointer}
|
|
87
|
+
.gnode .glabel{fill:var(--tx);font:700 12px var(--mono)}
|
|
88
|
+
.gnode .gsub{fill:var(--mut);font:9.5px var(--mono)}
|
|
89
|
+
.gnode .gcount{fill:var(--acc);font:700 10px var(--mono)}
|
|
90
|
+
.gedge{stroke:var(--thread);stroke-width:1;fill:none}
|
|
91
|
+
.gedge.on{stroke:var(--acc);stroke-width:1.8}
|
|
92
|
+
.gedge.dim,.gnode.dim{opacity:.22}
|
|
93
|
+
.gorder{fill:var(--mut);font:9px var(--mono);cursor:pointer}
|
|
94
|
+
.gorder.on{fill:var(--hi)}
|
|
95
|
+
.gstart{fill:var(--acc)}
|
|
96
|
+
|
|
97
|
+
button{font:700 10.5px var(--mono);letter-spacing:.04em;text-transform:uppercase;cursor:pointer;border:0;padding:5px 10px}
|
|
98
|
+
.btn-ghost{background:transparent;border:1px solid var(--acc);color:var(--acc)}
|
|
99
|
+
.btn-low{background:transparent;color:var(--mut);padding:2px 6px}
|
|
100
|
+
.btn-low:hover{color:var(--tx)}
|
|
101
|
+
.btn-low.copied{color:var(--hi)}
|
|
102
|
+
|
|
103
|
+
.curstat{font:700 13px var(--mono);padding:8px 12px;margin-bottom:12px;display:flex;gap:12px;align-items:center}
|
|
104
|
+
.curstat.pass{background:var(--pass-bg);color:var(--pass)}
|
|
105
|
+
.curstat.fail{background:var(--fail-bg);color:var(--fail)}
|
|
106
|
+
.curstat.run{background:var(--acc2);color:var(--acc)}
|
|
107
|
+
.curstat.idle{background:var(--idle-bg);color:var(--idle)}
|
|
108
|
+
.curstat .mut{font-weight:400;color:var(--mut)}
|
|
109
|
+
|
|
110
|
+
pre.code{background:var(--code);border-left:2px solid var(--acc);padding:10px 12px;margin:6px 0;font:12px/1.5 var(--mono);white-space:pre-wrap;word-break:break-word;color:var(--tx)}
|
|
111
|
+
pre.code .k{color:var(--acc)} pre.code .s{color:var(--pass)} pre.code .n{color:var(--hi)} pre.code .b{color:var(--warn)}
|
|
112
|
+
|
|
113
|
+
.verdict{border:1px solid var(--line);border-left:3px solid var(--idle);padding:8px 12px;margin:8px 0;background:var(--panel)}
|
|
114
|
+
.verdict.error{border-left-color:var(--fail)}
|
|
115
|
+
.verdict.warn,.verdict.warning{border-left-color:var(--warn)}
|
|
116
|
+
.verdict.info{border-left-color:var(--acc)}
|
|
117
|
+
.verdict .vhead{display:flex;gap:8px;align-items:baseline;font:12px var(--mono)}
|
|
118
|
+
.verdict .vhead b{color:var(--tx)}
|
|
119
|
+
.verdict .vbody{margin-top:4px;color:var(--tx)}
|
|
120
|
+
.verdict .vmeta{margin-top:4px;font:11px var(--mono);color:var(--mut)}
|
|
121
|
+
|
|
122
|
+
.empty{
|
|
123
|
+
border:1px dashed rgba(143,118,255,.35);
|
|
124
|
+
background:var(--code) repeating-linear-gradient(90deg, rgba(230,228,240,.05) 0 1px, transparent 1px 12px);
|
|
125
|
+
min-height:48px;display:flex;align-items:center;justify-content:center;
|
|
126
|
+
font:10.5px var(--mono);letter-spacing:.14em;color:var(--mut);text-transform:uppercase;
|
|
127
|
+
}
|
|
128
|
+
.meta-line{font:11px var(--mono);color:var(--mut);display:flex;gap:14px;flex-wrap:wrap;margin-bottom:10px}
|
|
129
|
+
.meta-line b{color:var(--tx);font-weight:400}
|
|
130
|
+
|
|
131
|
+
/* drop zone (file mode) */
|
|
132
|
+
.dropzone{
|
|
133
|
+
margin:8vh auto 0;max-width:640px;padding:60px 40px;text-align:center;cursor:pointer;
|
|
134
|
+
border:1px dashed rgba(143,118,255,.45);
|
|
135
|
+
background:var(--panel) repeating-linear-gradient(90deg, rgba(230,228,240,.04) 0 1px, transparent 1px 12px);
|
|
136
|
+
}
|
|
137
|
+
.dropzone.over{border-color:var(--hi)}
|
|
138
|
+
.dropzone .dz-title{font:700 13px var(--mono);letter-spacing:.2em;color:var(--tx);text-transform:uppercase}
|
|
139
|
+
.dropzone .dz-sub{margin-top:10px;font:11px var(--mono);color:var(--mut)}
|
|
140
|
+
</style>
|
|
141
|
+
</head>
|
|
142
|
+
<body>
|
|
143
|
+
<header>
|
|
144
|
+
<div class="brand">UNREAL MCP PROXY</div>
|
|
145
|
+
<div class="hdr-right" id="hdr-mode"></div>
|
|
146
|
+
</header>
|
|
147
|
+
<main>
|
|
148
|
+
<div id="drop-stage" hidden>
|
|
149
|
+
<div class="dropzone">
|
|
150
|
+
<div class="dz-title">DROP A SESSION .JSONL</div>
|
|
151
|
+
<div class="dz-sub">data/sessions/<session-id>.jsonl · or click to browse</div>
|
|
152
|
+
<input type="file" id="file-input" accept=".jsonl,.json" hidden>
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
<div id="session-stage" hidden>
|
|
156
|
+
<div class="curstat idle" id="curstat"></div>
|
|
157
|
+
<div class="meta-line" id="meta"></div>
|
|
158
|
+
<section class="panel">
|
|
159
|
+
<div class="wlabel"><span>THREAD</span><span class="cnt" id="thread-cnt"></span></div>
|
|
160
|
+
<div class="thread-line" id="thread"></div>
|
|
161
|
+
</section>
|
|
162
|
+
<section class="panel">
|
|
163
|
+
<div class="wlabel"><span>GRAPH</span><span class="cnt" id="graph-meta"></span></div>
|
|
164
|
+
<div class="graph-bar"><button class="btn-ghost" id="graph-play">PLAY</button></div>
|
|
165
|
+
<div class="graph" id="graph"></div>
|
|
166
|
+
</section>
|
|
167
|
+
<div class="cols">
|
|
168
|
+
<section class="panel">
|
|
169
|
+
<div class="wlabel"><span>CALLS</span><span class="cnt" id="calls-cnt"></span></div>
|
|
170
|
+
<ul class="rows" id="calls"></ul>
|
|
171
|
+
<div class="empty" id="calls-empty" hidden>NO CALLS</div>
|
|
172
|
+
</section>
|
|
173
|
+
<aside class="col-side">
|
|
174
|
+
<section class="panel" id="detail-panel">
|
|
175
|
+
<div class="wlabel"><span>CALL</span><span class="cnt" id="detail-id"></span></div>
|
|
176
|
+
<div id="detail"><div class="empty">SELECT A KNOT</div></div>
|
|
177
|
+
</section>
|
|
178
|
+
</aside>
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
</main>
|
|
182
|
+
<script>
|
|
183
|
+
"use strict";
|
|
184
|
+
(() => {
|
|
185
|
+
// src/session-model.ts
|
|
186
|
+
function requestLabel(body) {
|
|
187
|
+
const shape = body && typeof body === "object" && !Array.isArray(body) ? body : {};
|
|
188
|
+
if (!body || shape.omitted) return { title: "MCP request", category: "transport" };
|
|
189
|
+
if (shape.method === "initialize") return { title: "MCP initialize", category: "connection" };
|
|
190
|
+
if (shape.method === "notifications/initialized") return { title: "MCP initialized", category: "connection" };
|
|
191
|
+
if (shape.method === "tools/list") return { title: "List tools", category: "discovery" };
|
|
192
|
+
if (shape.method !== "tools/call") return { title: shape.method ?? "MCP request", category: "transport" };
|
|
193
|
+
const name = shape.params?.name ?? "unknown tool";
|
|
194
|
+
const args = shape.params?.arguments ?? {};
|
|
195
|
+
if (name === "list_toolsets" || name === "describe_toolset") {
|
|
196
|
+
return {
|
|
197
|
+
title: name === "list_toolsets" ? "List Unreal toolsets" : "Describe Unreal toolset",
|
|
198
|
+
category: "discovery",
|
|
199
|
+
toolName: name,
|
|
200
|
+
arguments: args
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
if (name === "call_tool") {
|
|
204
|
+
const toolsetName = typeof args.toolset_name === "string" ? args.toolset_name : void 0;
|
|
205
|
+
const toolName = typeof args.tool_name === "string" ? args.tool_name : void 0;
|
|
206
|
+
const toolset = toolsetName ? toolsetName.split(".").at(-1) : "TopLevel";
|
|
207
|
+
return {
|
|
208
|
+
title: `${toolset}.${toolName ?? "unknown"}`,
|
|
209
|
+
subtitle: toolsetName,
|
|
210
|
+
category: "unreal_tool",
|
|
211
|
+
toolName,
|
|
212
|
+
toolsetName,
|
|
213
|
+
arguments: args.arguments ?? {}
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
return { title: name, category: "mcp_tool", toolName: name, arguments: args };
|
|
217
|
+
}
|
|
218
|
+
function responseError(body, status) {
|
|
219
|
+
if (status != null && status >= 400) return `HTTP ${status}`;
|
|
220
|
+
const record = body && typeof body === "object" && !Array.isArray(body) ? body : null;
|
|
221
|
+
const messages = record?.transport === "sse" ? record.events ?? [] : [body];
|
|
222
|
+
for (const message of messages) {
|
|
223
|
+
const shape = message ?? {};
|
|
224
|
+
if (shape.error) return shape.error.message ?? "MCP error";
|
|
225
|
+
if (shape.result?.isError) return "Tool returned an error";
|
|
226
|
+
const content = shape.result?.content;
|
|
227
|
+
if (Array.isArray(content)) {
|
|
228
|
+
const text = content.map((item) => item?.text ?? "").join("\n");
|
|
229
|
+
if (/^(error|failed|exception|timeout)\s*:/i.test(text.trim())) return text.slice(0, 300);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
var round = (value) => Math.round((value ?? 0) * 100) / 100;
|
|
235
|
+
function median(values) {
|
|
236
|
+
if (!values.length) return 0;
|
|
237
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
238
|
+
const middle = Math.floor(sorted.length / 2);
|
|
239
|
+
return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
|
|
240
|
+
}
|
|
241
|
+
function timing(event) {
|
|
242
|
+
const total = event.durationMs ?? 0;
|
|
243
|
+
const headers = Math.min(total, event.responseHeadersMs ?? total);
|
|
244
|
+
const result = Math.min(total, event.resultEventMs ?? total);
|
|
245
|
+
return {
|
|
246
|
+
requestToHeadersMs: round(headers),
|
|
247
|
+
toolResultMs: round(Math.max(0, result - headers)),
|
|
248
|
+
proxyFinalizeMs: round(Math.max(0, total - result)),
|
|
249
|
+
completionReason: event.completionReason ?? "stream_end"
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
function targetNode(call) {
|
|
253
|
+
const actor = call.arguments?.actor;
|
|
254
|
+
const refPath = actor && typeof actor === "object" && !Array.isArray(actor) ? actor.refPath : void 0;
|
|
255
|
+
if (typeof refPath === "string" && call.toolName === "get_actor_transform") {
|
|
256
|
+
return { key: `actor:${refPath}`, label: refPath.split(/[.:]/).at(-1) ?? refPath, subtitle: call.toolName, kind: "target" };
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
key: `tool:${call.toolsetName ?? call.category}:${call.toolName ?? call.title}`,
|
|
260
|
+
label: call.toolName ?? call.title,
|
|
261
|
+
subtitle: call.toolsetName ?? call.category,
|
|
262
|
+
kind: "tool"
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
function buildFlowGraph(calls) {
|
|
266
|
+
const nodes = [];
|
|
267
|
+
const nodesByKey = /* @__PURE__ */ new Map();
|
|
268
|
+
const edges = [];
|
|
269
|
+
let previousNodeId = "start";
|
|
270
|
+
for (const [index, call] of calls.entries()) {
|
|
271
|
+
const descriptor = targetNode(call);
|
|
272
|
+
let node = nodesByKey.get(descriptor.key);
|
|
273
|
+
if (!node) {
|
|
274
|
+
node = { id: `node-${nodes.length + 1}`, ...descriptor, callIds: [] };
|
|
275
|
+
nodesByKey.set(descriptor.key, node);
|
|
276
|
+
nodes.push(node);
|
|
277
|
+
}
|
|
278
|
+
node.callIds.push(call.id);
|
|
279
|
+
edges.push({
|
|
280
|
+
id: `edge-${index + 1}`,
|
|
281
|
+
from: previousNodeId,
|
|
282
|
+
to: node.id,
|
|
283
|
+
order: index + 1,
|
|
284
|
+
callId: call.id,
|
|
285
|
+
durationMs: round(call.durationMs),
|
|
286
|
+
outcome: call.outcome
|
|
287
|
+
});
|
|
288
|
+
previousNodeId = node.id;
|
|
289
|
+
}
|
|
290
|
+
return { basis: "raw_mcp", nodes, edges };
|
|
291
|
+
}
|
|
292
|
+
function buildSessionModel(sessionId, events, webBaseUrl, now = Date.now()) {
|
|
293
|
+
const started = /* @__PURE__ */ new Map();
|
|
294
|
+
const completed = /* @__PURE__ */ new Set();
|
|
295
|
+
const annotations = [];
|
|
296
|
+
const calls = [];
|
|
297
|
+
const base = webBaseUrl.replace(/\/$/, "");
|
|
298
|
+
for (const event of events) {
|
|
299
|
+
if (event.type === "mcp_request_started") started.set(event.callId, event);
|
|
300
|
+
if (event.type === "ai_annotation") {
|
|
301
|
+
annotations.push({
|
|
302
|
+
callId: event.callId,
|
|
303
|
+
severity: event.severity ?? "info",
|
|
304
|
+
title: event.title,
|
|
305
|
+
summary: event.summary,
|
|
306
|
+
cause: event.cause,
|
|
307
|
+
suggestion: event.suggestion,
|
|
308
|
+
author: event.author ?? "agent",
|
|
309
|
+
timestamp: event.timestamp
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
if (event.type === "mcp_request_completed" || event.type === "mcp_request_failed") {
|
|
313
|
+
const payload = event;
|
|
314
|
+
const callId = payload.callId ?? "";
|
|
315
|
+
const begin = started.get(callId);
|
|
316
|
+
completed.add(callId);
|
|
317
|
+
const beginBody = begin?.body ?? null;
|
|
318
|
+
const label = requestLabel(beginBody);
|
|
319
|
+
const error = event.type === "mcp_request_failed" ? payload.error ?? "proxy or upstream connection failed" : responseError(payload.body, payload.status ?? null);
|
|
320
|
+
calls.push({
|
|
321
|
+
id: callId,
|
|
322
|
+
sequence: begin?.sequence ?? event.sequence,
|
|
323
|
+
startedAt: begin?.timestamp ?? event.timestamp,
|
|
324
|
+
completedAt: event.timestamp,
|
|
325
|
+
durationMs: payload.durationMs ?? null,
|
|
326
|
+
statusCode: payload.status ?? null,
|
|
327
|
+
outcome: error ? "error" : "success",
|
|
328
|
+
error,
|
|
329
|
+
request: beginBody,
|
|
330
|
+
response: payload.body ?? null,
|
|
331
|
+
requestHeaders: begin?.headers ?? null,
|
|
332
|
+
responseHeaders: payload.headers ?? null,
|
|
333
|
+
timing: timing(payload),
|
|
334
|
+
clientSource: begin?.clientSource ?? "agent",
|
|
335
|
+
operationId: begin?.operationId,
|
|
336
|
+
operationTitle: begin?.operationTitle,
|
|
337
|
+
operationStep: begin?.operationStep,
|
|
338
|
+
deepLink: `${base}/sessions/${sessionId}?call=${callId}`,
|
|
339
|
+
annotations: [],
|
|
340
|
+
isSystem: false,
|
|
341
|
+
...label
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
for (const [callId, begin] of started) {
|
|
346
|
+
if (completed.has(callId)) continue;
|
|
347
|
+
const label = requestLabel(begin.body ?? null);
|
|
348
|
+
calls.push({
|
|
349
|
+
id: callId,
|
|
350
|
+
sequence: begin.sequence,
|
|
351
|
+
startedAt: begin.timestamp,
|
|
352
|
+
completedAt: null,
|
|
353
|
+
durationMs: Math.max(0, now - new Date(begin.timestamp).getTime()),
|
|
354
|
+
statusCode: null,
|
|
355
|
+
outcome: "running",
|
|
356
|
+
error: null,
|
|
357
|
+
request: begin.body ?? null,
|
|
358
|
+
response: null,
|
|
359
|
+
requestHeaders: begin.headers ?? null,
|
|
360
|
+
responseHeaders: null,
|
|
361
|
+
timing: null,
|
|
362
|
+
clientSource: begin.clientSource ?? "agent",
|
|
363
|
+
operationId: begin.operationId,
|
|
364
|
+
operationTitle: begin.operationTitle,
|
|
365
|
+
operationStep: begin.operationStep,
|
|
366
|
+
deepLink: `${base}/sessions/${sessionId}?call=${callId}`,
|
|
367
|
+
annotations: [],
|
|
368
|
+
isSystem: false,
|
|
369
|
+
...label
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
for (const call of calls) {
|
|
373
|
+
call.isSystem = call.clientSource === "smoke-test" || call.category === "connection" || call.category === "discovery";
|
|
374
|
+
}
|
|
375
|
+
const workCalls = calls.filter((call) => !call.isSystem);
|
|
376
|
+
const errors = workCalls.filter((call) => call.outcome === "error").length;
|
|
377
|
+
const running = workCalls.filter((call) => call.outcome === "running").length;
|
|
378
|
+
const completedWork = workCalls.filter((call) => call.outcome !== "running");
|
|
379
|
+
const recent = [...completedWork].sort((a, b) => b.sequence - a.sequence).slice(0, 20);
|
|
380
|
+
const durationsByTool = /* @__PURE__ */ new Map();
|
|
381
|
+
for (const call of completedWork) {
|
|
382
|
+
const values = durationsByTool.get(call.title) ?? [];
|
|
383
|
+
values.push(call.durationMs ?? 0);
|
|
384
|
+
durationsByTool.set(call.title, values);
|
|
385
|
+
}
|
|
386
|
+
for (const call of completedWork) {
|
|
387
|
+
const samples = durationsByTool.get(call.title) ?? [];
|
|
388
|
+
const baseline = median(samples);
|
|
389
|
+
const ratio = baseline ? (call.durationMs ?? 0) / baseline : 1;
|
|
390
|
+
call.performance = {
|
|
391
|
+
sampleCount: samples.length,
|
|
392
|
+
medianMs: round(baseline),
|
|
393
|
+
ratio: round(ratio),
|
|
394
|
+
classification: samples.length >= 3 && ratio >= 2 && (call.durationMs ?? 0) >= 50 ? "slow" : "normal"
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
for (const call of calls) {
|
|
398
|
+
const latestByAuthor = /* @__PURE__ */ new Map();
|
|
399
|
+
for (const note of annotations.filter((item) => item.callId === call.id)) {
|
|
400
|
+
latestByAuthor.set(note.author, note);
|
|
401
|
+
}
|
|
402
|
+
call.annotations = [...latestByAuthor.values()];
|
|
403
|
+
}
|
|
404
|
+
const chronological2 = [...workCalls].sort((a, b) => a.sequence - b.sequence);
|
|
405
|
+
return {
|
|
406
|
+
id: sessionId,
|
|
407
|
+
startedAt: events.find((event) => event.type === "session_started")?.timestamp,
|
|
408
|
+
lastEventAt: events.at(-1)?.timestamp,
|
|
409
|
+
summary: {
|
|
410
|
+
totalCalls: workCalls.length,
|
|
411
|
+
successes: completedWork.length - errors,
|
|
412
|
+
errors,
|
|
413
|
+
running,
|
|
414
|
+
systemCalls: calls.length - workCalls.length,
|
|
415
|
+
averageDurationMs: recent.length ? Math.round(recent.reduce((sum, call) => sum + (call.durationMs ?? 0), 0) / recent.length) : 0,
|
|
416
|
+
medianDurationMs: round(median(recent.map((call) => call.durationMs ?? 0)))
|
|
417
|
+
},
|
|
418
|
+
graph: buildFlowGraph(chronological2),
|
|
419
|
+
calls: calls.sort((a, b) => b.sequence - a.sequence),
|
|
420
|
+
annotations,
|
|
421
|
+
rawEventCount: events.length
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// viewer/src/helpers.ts
|
|
426
|
+
var $ = (selector) => {
|
|
427
|
+
const node = document.querySelector(selector);
|
|
428
|
+
if (!node) throw new Error(`element not found: ${selector}`);
|
|
429
|
+
return node;
|
|
430
|
+
};
|
|
431
|
+
var el = (tag, cls, text) => {
|
|
432
|
+
const node = document.createElement(tag);
|
|
433
|
+
if (cls) node.className = cls;
|
|
434
|
+
if (text != null) node.textContent = text;
|
|
435
|
+
return node;
|
|
436
|
+
};
|
|
437
|
+
var SVG_NS = "http://www.w3.org/2000/svg";
|
|
438
|
+
var svgEl = (tag, attrs = {}, cls) => {
|
|
439
|
+
const node = document.createElementNS(SVG_NS, tag);
|
|
440
|
+
for (const [key, value] of Object.entries(attrs)) node.setAttribute(key, String(value));
|
|
441
|
+
if (cls) node.setAttribute("class", cls);
|
|
442
|
+
return node;
|
|
443
|
+
};
|
|
444
|
+
var fmtMs = (ms) => {
|
|
445
|
+
if (ms == null) return "-";
|
|
446
|
+
if (ms < 1e3) return `${Math.round(ms)}ms`;
|
|
447
|
+
if (ms < 6e4) return `${(ms / 1e3).toFixed(1)}s`;
|
|
448
|
+
return `${Math.floor(ms / 6e4)}m${Math.round(ms % 6e4 / 1e3)}s`;
|
|
449
|
+
};
|
|
450
|
+
var fmtTime = (iso) => {
|
|
451
|
+
if (!iso) return "-";
|
|
452
|
+
const date = new Date(iso);
|
|
453
|
+
const pad = (value) => String(value).padStart(2, "0");
|
|
454
|
+
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
|
455
|
+
};
|
|
456
|
+
var outcomeState = (call) => call.outcome === "running" ? "run" : call.outcome === "error" ? "fail" : "pass";
|
|
457
|
+
function renderJson(value) {
|
|
458
|
+
const pre = el("pre", "code");
|
|
459
|
+
const text = JSON.stringify(value, null, 2) ?? "null";
|
|
460
|
+
const frag = document.createDocumentFragment();
|
|
461
|
+
const re = /("(?:[^"\\]|\\.)*")(\s*:)?|(-?\d+\.?\d*(?:e[+-]?\d+)?)|(\btrue\b|\bfalse\b|\bnull\b)/gi;
|
|
462
|
+
let last = 0;
|
|
463
|
+
let match;
|
|
464
|
+
while (match = re.exec(text)) {
|
|
465
|
+
if (match.index > last) frag.append(text.slice(last, match.index));
|
|
466
|
+
const span = document.createElement("span");
|
|
467
|
+
span.textContent = match[1] ?? match[3] ?? match[4] ?? "";
|
|
468
|
+
span.className = match[1] ? match[2] ? "k" : "s" : match[3] ? "n" : "b";
|
|
469
|
+
frag.append(span);
|
|
470
|
+
if (match[2]) frag.append(match[2]);
|
|
471
|
+
last = re.lastIndex;
|
|
472
|
+
}
|
|
473
|
+
frag.append(text.slice(last));
|
|
474
|
+
pre.append(frag);
|
|
475
|
+
return pre;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// viewer/src/unwrap.ts
|
|
479
|
+
var isRecord = (value) => !!value && typeof value === "object" && !Array.isArray(value);
|
|
480
|
+
function unwrapRequest(body) {
|
|
481
|
+
if (!isRecord(body)) return { view: body, changed: false };
|
|
482
|
+
if (body.method !== "tools/call" || !isRecord(body.params)) return { view: body, changed: false };
|
|
483
|
+
const params = body.params;
|
|
484
|
+
const args = isRecord(params.arguments) ? params.arguments : null;
|
|
485
|
+
if (params.name === "call_tool" && args) {
|
|
486
|
+
return { view: args.arguments ?? {}, changed: true };
|
|
487
|
+
}
|
|
488
|
+
return { view: args ?? params, changed: true };
|
|
489
|
+
}
|
|
490
|
+
function unwrapResponse(body) {
|
|
491
|
+
if (!isRecord(body)) return { view: body, changed: false };
|
|
492
|
+
const messages = body.transport === "sse" && Array.isArray(body.events) ? body.events : [body];
|
|
493
|
+
let final = null;
|
|
494
|
+
for (const message of messages) {
|
|
495
|
+
if (isRecord(message) && (message.result !== void 0 || message.error !== void 0)) final = message;
|
|
496
|
+
}
|
|
497
|
+
if (!final) return { view: body, changed: false };
|
|
498
|
+
if (final.error !== void 0) return { view: final.error, changed: true };
|
|
499
|
+
const result = final.result;
|
|
500
|
+
if (!isRecord(result)) return { view: result, changed: true };
|
|
501
|
+
const content = result.content;
|
|
502
|
+
if (!Array.isArray(content) || !content.length) return { view: result, changed: true };
|
|
503
|
+
const parsed = content.map((item) => isRecord(item) && typeof item.text === "string" ? parseMaybeJson(item.text) : item);
|
|
504
|
+
return { view: parsed.length === 1 ? parsed[0] : parsed, changed: true };
|
|
505
|
+
}
|
|
506
|
+
function parseMaybeJson(text) {
|
|
507
|
+
const trimmed = text.trim();
|
|
508
|
+
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
509
|
+
try {
|
|
510
|
+
return JSON.parse(trimmed);
|
|
511
|
+
} catch {
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return text;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// viewer/src/thread.ts
|
|
518
|
+
var STEP = 26;
|
|
519
|
+
var ROW_H = 26;
|
|
520
|
+
var PAD = 10;
|
|
521
|
+
var KNOT = 9;
|
|
522
|
+
var HALF = KNOT / 2;
|
|
523
|
+
var GAP = 6;
|
|
524
|
+
var THREAD_Y = 16;
|
|
525
|
+
function renderThread(container, calls, focusCallId2, onSelect) {
|
|
526
|
+
const width = Math.max(container.clientWidth || 1200, 240);
|
|
527
|
+
container.textContent = "";
|
|
528
|
+
const perRow = Math.max(8, Math.floor((width - PAD * 2 - KNOT) / STEP) + 1);
|
|
529
|
+
const rows = Math.max(1, Math.ceil(calls.length / perRow));
|
|
530
|
+
const track = el("div", "track");
|
|
531
|
+
track.style.height = `${rows * ROW_H + 6}px`;
|
|
532
|
+
container.style.height = `${rows * ROW_H + 10}px`;
|
|
533
|
+
const positions = calls.map((_, index) => {
|
|
534
|
+
const row = Math.floor(index / perRow);
|
|
535
|
+
const col = index % perRow;
|
|
536
|
+
const visualCol = row % 2 === 0 ? col : perRow - 1 - col;
|
|
537
|
+
return { cx: PAD + visualCol * STEP + HALF, y: row * ROW_H, row };
|
|
538
|
+
});
|
|
539
|
+
const turnX = (row) => row % 2 === 0 ? PAD + (perRow - 1) * STEP + KNOT + 6 : PAD - 7;
|
|
540
|
+
const failTrim = (call) => outcomeState(call) === "fail" ? HALF + GAP : 0;
|
|
541
|
+
for (let index = 1; index < calls.length; index += 1) {
|
|
542
|
+
const prev = positions[index - 1];
|
|
543
|
+
const curr = positions[index];
|
|
544
|
+
const prevTrim = failTrim(calls[index - 1]);
|
|
545
|
+
const currTrim = failTrim(calls[index]);
|
|
546
|
+
if (prev.row === curr.row) {
|
|
547
|
+
const dir = Math.sign(curr.cx - prev.cx) || 1;
|
|
548
|
+
addH(track, prev.cx + dir * prevTrim, curr.cx - dir * currTrim, prev.y + THREAD_Y);
|
|
549
|
+
} else {
|
|
550
|
+
const edge = turnX(prev.row);
|
|
551
|
+
const dirOut = Math.sign(edge - prev.cx) || 1;
|
|
552
|
+
const dirIn = Math.sign(curr.cx - edge) || 1;
|
|
553
|
+
addH(track, prev.cx + dirOut * prevTrim, edge, prev.y + THREAD_Y);
|
|
554
|
+
addV(track, edge, prev.y + THREAD_Y, curr.y + THREAD_Y);
|
|
555
|
+
addH(track, edge, curr.cx - dirIn * currTrim, curr.y + THREAD_Y);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
const lastCall = calls.at(-1);
|
|
559
|
+
const lastPos = positions.at(-1);
|
|
560
|
+
if (lastCall && lastPos && lastCall.outcome === "running") {
|
|
561
|
+
const dir = lastPos.row % 2 === 0 ? 1 : -1;
|
|
562
|
+
const from = lastPos.cx + dir * HALF;
|
|
563
|
+
const seg = el("span", "drawseg");
|
|
564
|
+
seg.style.left = `${Math.min(from, from + dir * 20)}px`;
|
|
565
|
+
seg.style.width = "20px";
|
|
566
|
+
seg.style.top = `${lastPos.y + THREAD_Y}px`;
|
|
567
|
+
track.append(seg);
|
|
568
|
+
}
|
|
569
|
+
const knots = /* @__PURE__ */ new Map();
|
|
570
|
+
const applyKnotStyle = (element, pos, focused) => {
|
|
571
|
+
const size = focused ? 11 : KNOT;
|
|
572
|
+
element.classList.toggle("focus", focused);
|
|
573
|
+
element.style.left = `${pos.cx - size / 2}px`;
|
|
574
|
+
element.style.top = `${pos.y + THREAD_Y - size / 2}px`;
|
|
575
|
+
element.style.width = `${size}px`;
|
|
576
|
+
element.style.height = `${size}px`;
|
|
577
|
+
};
|
|
578
|
+
calls.forEach((call, index) => {
|
|
579
|
+
const pos = positions[index];
|
|
580
|
+
const knotEl = el("span", `tknot knot ${outcomeState(call)}`);
|
|
581
|
+
applyKnotStyle(knotEl, pos, call.id === focusCallId2);
|
|
582
|
+
knotEl.title = `${call.title} ${fmtMs(call.durationMs)}`;
|
|
583
|
+
knotEl.addEventListener("click", () => onSelect(call.id));
|
|
584
|
+
track.append(knotEl);
|
|
585
|
+
knots.set(call.id, { element: knotEl, pos });
|
|
586
|
+
});
|
|
587
|
+
container.append(track);
|
|
588
|
+
return {
|
|
589
|
+
setFocus(previous, next) {
|
|
590
|
+
for (const callId of [previous, next]) {
|
|
591
|
+
if (!callId) continue;
|
|
592
|
+
const knot = knots.get(callId);
|
|
593
|
+
if (knot) applyKnotStyle(knot.element, knot.pos, callId === next);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
function addH(track, fromX, toX, y) {
|
|
599
|
+
const left = Math.min(fromX, toX);
|
|
600
|
+
const w = Math.abs(toX - fromX);
|
|
601
|
+
if (w < 1) return;
|
|
602
|
+
const seg = el("span", "seg");
|
|
603
|
+
seg.style.left = `${left}px`;
|
|
604
|
+
seg.style.width = `${w}px`;
|
|
605
|
+
seg.style.top = `${y}px`;
|
|
606
|
+
track.append(seg);
|
|
607
|
+
}
|
|
608
|
+
function addV(track, x, fromY, toY) {
|
|
609
|
+
const top = Math.min(fromY, toY);
|
|
610
|
+
const h = Math.abs(toY - fromY);
|
|
611
|
+
if (h < 1) return;
|
|
612
|
+
const seg = el("span", "vseg");
|
|
613
|
+
seg.style.left = `${x}px`;
|
|
614
|
+
seg.style.top = `${top}px`;
|
|
615
|
+
seg.style.height = `${h}px`;
|
|
616
|
+
track.append(seg);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// viewer/src/graph.ts
|
|
620
|
+
function renderGraph(container, meta, graph, focusCallId2, onSelect) {
|
|
621
|
+
const width0 = Math.max(container.clientWidth || 1200, 400);
|
|
622
|
+
container.textContent = "";
|
|
623
|
+
meta.textContent = `${graph.nodes.length}n \xB7 ${graph.edges.length}c`;
|
|
624
|
+
if (!graph.nodes.length) return null;
|
|
625
|
+
const CW = 300, RH = 104, NW = 220, NH = 46, PAD2 = 44;
|
|
626
|
+
const COLS = Math.max(2, Math.floor((width0 - PAD2 * 2) / CW));
|
|
627
|
+
const indexById = new Map(graph.nodes.map((node, index) => [node.id, index]));
|
|
628
|
+
const center = (nodeId) => {
|
|
629
|
+
const index = indexById.get(nodeId) ?? 0;
|
|
630
|
+
return {
|
|
631
|
+
x: PAD2 + index % COLS * CW + (Math.floor(index / COLS) % 2 ? 40 : 0) + NW / 2,
|
|
632
|
+
y: 30 + Math.floor(index / COLS) * RH + NH / 2
|
|
633
|
+
};
|
|
634
|
+
};
|
|
635
|
+
const rows = Math.ceil(graph.nodes.length / COLS);
|
|
636
|
+
const width = PAD2 * 2 + Math.min(graph.nodes.length, COLS) * CW;
|
|
637
|
+
const height = 30 + rows * RH + 20;
|
|
638
|
+
const svg = svgEl("svg", { width, height, viewBox: `0 0 ${width} ${height}` });
|
|
639
|
+
const start = { x: 16, y: center(graph.nodes[0].id).y };
|
|
640
|
+
svg.append(svgEl("circle", { cx: start.x, cy: start.y, r: 4 }, "gstart"));
|
|
641
|
+
const failedNodes = new Set(graph.edges.filter((edge) => edge.outcome === "error").map((edge) => edge.to));
|
|
642
|
+
const pairSeen = /* @__PURE__ */ new Map();
|
|
643
|
+
const edgeShapes = [];
|
|
644
|
+
for (const edge of graph.edges) {
|
|
645
|
+
const from = edge.from === "start" ? start : center(edge.from);
|
|
646
|
+
const to = center(edge.to);
|
|
647
|
+
const pair = `${edge.from}-${edge.to}`;
|
|
648
|
+
const dup = pairSeen.get(pair) ?? 0;
|
|
649
|
+
pairSeen.set(pair, dup + 1);
|
|
650
|
+
let d;
|
|
651
|
+
let mid;
|
|
652
|
+
if (edge.from === edge.to) {
|
|
653
|
+
const loop = 18 + dup * 10;
|
|
654
|
+
d = `M ${to.x - 24} ${to.y - NH / 2} C ${to.x - 24} ${to.y - NH / 2 - loop}, ${to.x + 24} ${to.y - NH / 2 - loop}, ${to.x + 24} ${to.y - NH / 2}`;
|
|
655
|
+
mid = { x: to.x, y: to.y - NH / 2 - loop + 3 };
|
|
656
|
+
} else {
|
|
657
|
+
const dx = to.x - from.x, dy = to.y - from.y;
|
|
658
|
+
const norm = Math.hypot(dx, dy) || 1;
|
|
659
|
+
const off = (14 + dup * 12) * (dup % 2 ? -1 : 1);
|
|
660
|
+
const cx = from.x + dx / 2 - dy / norm * off;
|
|
661
|
+
const cy = from.y + dy / 2 + dx / norm * off;
|
|
662
|
+
d = `M ${from.x} ${from.y} Q ${cx} ${cy} ${to.x} ${to.y}`;
|
|
663
|
+
mid = { x: (from.x + 2 * cx + to.x) / 4, y: (from.y + 2 * cy + to.y) / 4 };
|
|
664
|
+
}
|
|
665
|
+
const path = svgEl("path", { d }, "gedge");
|
|
666
|
+
const label = svgEl("text", { x: mid.x, y: mid.y, "text-anchor": "middle" }, "gorder");
|
|
667
|
+
label.textContent = String(edge.order);
|
|
668
|
+
label.addEventListener("click", () => onSelect(edge.callId));
|
|
669
|
+
svg.append(path, label);
|
|
670
|
+
edgeShapes.push({ order: edge.order, callId: edge.callId, toId: edge.to, path, label });
|
|
671
|
+
}
|
|
672
|
+
const nodeShapes = [];
|
|
673
|
+
for (const node of graph.nodes) {
|
|
674
|
+
const c = center(node.id);
|
|
675
|
+
const group = svgEl("g", {}, `gnode${failedNodes.has(node.id) ? " fail" : ""}`);
|
|
676
|
+
group.append(svgEl("rect", { x: c.x - NW / 2, y: c.y - NH / 2, width: NW, height: NH }));
|
|
677
|
+
const label = svgEl("text", { x: c.x - NW / 2 + 10, y: c.y - 4 }, "glabel");
|
|
678
|
+
label.textContent = node.label.length > 24 ? `${node.label.slice(0, 23)}\u2026` : node.label;
|
|
679
|
+
const sub = svgEl("text", { x: c.x - NW / 2 + 10, y: c.y + 13 }, "gsub");
|
|
680
|
+
sub.textContent = node.subtitle.length > 34 ? `\u2026${node.subtitle.slice(-33)}` : node.subtitle;
|
|
681
|
+
const count = svgEl("text", { x: c.x + NW / 2 - 8, y: c.y - 10, "text-anchor": "end" }, "gcount");
|
|
682
|
+
if (node.callIds.length > 1) count.textContent = `\xD7${node.callIds.length}`;
|
|
683
|
+
group.append(label, sub, count);
|
|
684
|
+
group.addEventListener("click", () => {
|
|
685
|
+
const last = node.callIds.at(-1);
|
|
686
|
+
if (last) onSelect(last);
|
|
687
|
+
});
|
|
688
|
+
svg.append(group);
|
|
689
|
+
nodeShapes.push({ node, group });
|
|
690
|
+
}
|
|
691
|
+
container.append(svg);
|
|
692
|
+
const view = { edgeShapes, nodeShapes };
|
|
693
|
+
setGraphFocus(view, focusCallId2);
|
|
694
|
+
return view;
|
|
695
|
+
}
|
|
696
|
+
function setGraphFocus(view, callId) {
|
|
697
|
+
const focusEdge = view.edgeShapes.find((shape) => shape.callId === callId) ?? null;
|
|
698
|
+
for (const shape of view.edgeShapes) {
|
|
699
|
+
const on = shape === focusEdge;
|
|
700
|
+
shape.path.classList.toggle("on", on);
|
|
701
|
+
shape.label.classList.toggle("on", on);
|
|
702
|
+
shape.path.classList.remove("dim");
|
|
703
|
+
shape.label.classList.remove("dim");
|
|
704
|
+
}
|
|
705
|
+
for (const shape of view.nodeShapes) {
|
|
706
|
+
shape.group.classList.toggle("on", focusEdge != null && shape.node.id === focusEdge.toId);
|
|
707
|
+
shape.group.classList.remove("dim");
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
function createReplay(view, onDone) {
|
|
711
|
+
let step = -1;
|
|
712
|
+
const ordered = [...view.edgeShapes].sort((a, b) => a.order - b.order);
|
|
713
|
+
return {
|
|
714
|
+
tick() {
|
|
715
|
+
step += 1;
|
|
716
|
+
if (step >= ordered.length) {
|
|
717
|
+
onDone();
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
for (const [index, shape] of ordered.entries()) {
|
|
721
|
+
shape.path.classList.toggle("on", index === step);
|
|
722
|
+
shape.label.classList.toggle("on", index === step);
|
|
723
|
+
shape.path.classList.toggle("dim", index > step);
|
|
724
|
+
shape.label.classList.toggle("dim", index > step);
|
|
725
|
+
}
|
|
726
|
+
const current = ordered[step];
|
|
727
|
+
const visited = new Set(ordered.slice(0, step + 1).map((shape) => shape.toId));
|
|
728
|
+
for (const shape of view.nodeShapes) {
|
|
729
|
+
shape.group.classList.toggle("on", shape.node.id === current.toId);
|
|
730
|
+
shape.group.classList.toggle("dim", !visited.has(shape.node.id));
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// viewer/src/main.ts
|
|
737
|
+
var model = null;
|
|
738
|
+
var focusCallId = new URLSearchParams(location.search).get("call");
|
|
739
|
+
var threadView = null;
|
|
740
|
+
var graphView = null;
|
|
741
|
+
var replayTimer = null;
|
|
742
|
+
var callRows = /* @__PURE__ */ new Map();
|
|
743
|
+
var servedSession = location.protocol.startsWith("http") ? /^\/sessions\/([0-9a-f-]+)$/i.exec(location.pathname)?.[1] ?? null : null;
|
|
744
|
+
var chronological = () => model ? [...model.calls].filter((call) => !call.isSystem).sort((a, b) => a.sequence - b.sequence) : [];
|
|
745
|
+
function setModelFromEvents(sessionId, events) {
|
|
746
|
+
model = buildSessionModel(sessionId, events, location.origin.startsWith("http") ? location.origin : "");
|
|
747
|
+
if (focusCallId && !model.calls.some((call) => call.id === focusCallId)) focusCallId = null;
|
|
748
|
+
renderAll();
|
|
749
|
+
}
|
|
750
|
+
function renderAll() {
|
|
751
|
+
if (!model) return;
|
|
752
|
+
$("#drop-stage").hidden = true;
|
|
753
|
+
$("#session-stage").hidden = false;
|
|
754
|
+
renderCurstat();
|
|
755
|
+
renderThreadPanel();
|
|
756
|
+
renderGraphPanel();
|
|
757
|
+
renderCalls();
|
|
758
|
+
void renderDetail();
|
|
759
|
+
}
|
|
760
|
+
function renderCurstat() {
|
|
761
|
+
if (!model) return;
|
|
762
|
+
const bar = $("#curstat");
|
|
763
|
+
const { summary } = model;
|
|
764
|
+
const state = summary.running ? "run" : summary.errors ? "fail" : "pass";
|
|
765
|
+
bar.className = `curstat ${state}`;
|
|
766
|
+
bar.textContent = "";
|
|
767
|
+
bar.append(el("span", "", summary.running ? "RUNNING" : summary.errors ? "FAILED" : "OK"));
|
|
768
|
+
bar.append(el("span", "mut num", `${summary.totalCalls} calls \xB7 ${summary.errors} failed \xB7 ${summary.running} running`));
|
|
769
|
+
const meta = $("#meta");
|
|
770
|
+
meta.textContent = "";
|
|
771
|
+
const entries = [
|
|
772
|
+
["session", model.id.slice(0, 8)],
|
|
773
|
+
["started", fmtTime(model.startedAt)],
|
|
774
|
+
["last", fmtTime(model.lastEventAt)],
|
|
775
|
+
["events", String(model.rawEventCount)]
|
|
776
|
+
];
|
|
777
|
+
for (const [key, value] of entries) {
|
|
778
|
+
const span = el("span", "", `${key} `);
|
|
779
|
+
span.append(el("b", "", value));
|
|
780
|
+
meta.append(span);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
function renderThreadPanel() {
|
|
784
|
+
const calls = chronological();
|
|
785
|
+
$("#thread-cnt").textContent = String(calls.length);
|
|
786
|
+
threadView = renderThread($("#thread"), calls, focusCallId, selectCall);
|
|
787
|
+
}
|
|
788
|
+
function renderGraphPanel() {
|
|
789
|
+
if (!model) return;
|
|
790
|
+
graphView = renderGraph($("#graph"), $("#graph-meta"), model.graph, focusCallId, selectCall);
|
|
791
|
+
}
|
|
792
|
+
function stopReplay() {
|
|
793
|
+
if (replayTimer) clearInterval(replayTimer);
|
|
794
|
+
replayTimer = null;
|
|
795
|
+
$("#graph-play").textContent = "PLAY";
|
|
796
|
+
renderGraphPanel();
|
|
797
|
+
}
|
|
798
|
+
$("#graph-play").addEventListener("click", () => {
|
|
799
|
+
if (replayTimer) {
|
|
800
|
+
stopReplay();
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
if (!graphView) return;
|
|
804
|
+
$("#graph-play").textContent = "STOP";
|
|
805
|
+
const replay = createReplay(graphView, stopReplay);
|
|
806
|
+
replay.tick();
|
|
807
|
+
replayTimer = setInterval(() => replay.tick(), 650);
|
|
808
|
+
});
|
|
809
|
+
function renderCalls() {
|
|
810
|
+
const list = $("#calls");
|
|
811
|
+
const scrollTop = list.scrollTop;
|
|
812
|
+
list.textContent = "";
|
|
813
|
+
callRows = /* @__PURE__ */ new Map();
|
|
814
|
+
const calls = [...chronological()].reverse();
|
|
815
|
+
$("#calls-cnt").textContent = String(calls.length);
|
|
816
|
+
$("#calls-empty").hidden = calls.length > 0;
|
|
817
|
+
for (const call of calls) {
|
|
818
|
+
const item = el("li");
|
|
819
|
+
if (call.id === focusCallId) item.classList.add("on");
|
|
820
|
+
item.append(el("span", `knot ${outcomeState(call)}`));
|
|
821
|
+
item.append(el("span", "grow", call.title));
|
|
822
|
+
if (call.annotations.length) item.append(el("span", "badge warn", `A${call.annotations.length}`));
|
|
823
|
+
const duration = el("span", "mut num", call.outcome === "running" ? fmtMs(Date.now() - new Date(call.startedAt).getTime()) : fmtMs(call.durationMs));
|
|
824
|
+
item.append(duration);
|
|
825
|
+
item.append(el("span", "mut num", fmtTime(call.startedAt).slice(6)));
|
|
826
|
+
item.addEventListener("click", () => selectCall(call.id));
|
|
827
|
+
list.append(item);
|
|
828
|
+
callRows.set(call.id, { item, duration, call });
|
|
829
|
+
}
|
|
830
|
+
list.scrollTop = scrollTop;
|
|
831
|
+
}
|
|
832
|
+
function updateCallSelection(previous) {
|
|
833
|
+
if (previous) callRows.get(previous)?.item.classList.remove("on");
|
|
834
|
+
if (focusCallId) callRows.get(focusCallId)?.item.classList.add("on");
|
|
835
|
+
}
|
|
836
|
+
async function renderDetail() {
|
|
837
|
+
const container = $("#detail");
|
|
838
|
+
const call = model?.calls.find((item) => item.id === focusCallId) ?? null;
|
|
839
|
+
if (!call) {
|
|
840
|
+
container.textContent = "";
|
|
841
|
+
container.append(el("div", "empty", "SELECT A KNOT"));
|
|
842
|
+
$("#detail-id").textContent = "";
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
$("#detail-id").textContent = call.id.slice(0, 8);
|
|
846
|
+
container.textContent = "";
|
|
847
|
+
const head = el("div", "meta-line");
|
|
848
|
+
const state = outcomeState(call);
|
|
849
|
+
head.append(el("span", `badge ${state}`, call.outcome));
|
|
850
|
+
const fields = [
|
|
851
|
+
["tool", call.toolName ?? call.title],
|
|
852
|
+
["toolset", call.toolsetName],
|
|
853
|
+
["http", call.statusCode],
|
|
854
|
+
["dur", fmtMs(call.durationMs)],
|
|
855
|
+
["slow", call.performance?.classification === "slow" ? `${call.performance.ratio}x median` : null]
|
|
856
|
+
];
|
|
857
|
+
for (const [key, value] of fields) {
|
|
858
|
+
if (value == null) continue;
|
|
859
|
+
const span = el("span", "", `${key} `);
|
|
860
|
+
span.append(el("b", "", String(value)));
|
|
861
|
+
head.append(span);
|
|
862
|
+
}
|
|
863
|
+
container.append(head);
|
|
864
|
+
if (call.error) {
|
|
865
|
+
const err = el("div", "verdict error");
|
|
866
|
+
err.append(el("div", "vbody", call.error));
|
|
867
|
+
container.append(err);
|
|
868
|
+
}
|
|
869
|
+
for (const note of call.annotations) {
|
|
870
|
+
const card = el("div", `verdict ${note.severity}`);
|
|
871
|
+
const headEl = el("div", "vhead");
|
|
872
|
+
headEl.append(el("span", `badge ${note.severity === "error" ? "fail" : note.severity === "warn" || note.severity === "warning" ? "warn" : "run"}`, note.severity));
|
|
873
|
+
headEl.append(el("b", "", note.title));
|
|
874
|
+
card.append(headEl);
|
|
875
|
+
card.append(el("div", "vbody", note.summary));
|
|
876
|
+
if (note.cause) card.append(el("div", "vbody mut", note.cause));
|
|
877
|
+
if (note.suggestion) card.append(el("div", "vbody", note.suggestion));
|
|
878
|
+
card.append(el("div", "vmeta", `${note.author}${note.timestamp ? ` \xB7 ${fmtTime(note.timestamp)}` : ""}`));
|
|
879
|
+
container.append(card);
|
|
880
|
+
}
|
|
881
|
+
appendBodySection(container, "REQUEST", call.request, unwrapRequest, "NO DATA");
|
|
882
|
+
appendBodySection(container, "RESPONSE", call.response, unwrapResponse, call.outcome === "running" ? "RUNNING" : "NO DATA");
|
|
883
|
+
}
|
|
884
|
+
function appendBodySection(container, title, body, unwrap, emptyText) {
|
|
885
|
+
const wlabel = el("div", "wlabel");
|
|
886
|
+
wlabel.append(el("span", "", title));
|
|
887
|
+
container.append(wlabel);
|
|
888
|
+
if (body == null) {
|
|
889
|
+
container.append(el("div", "empty", emptyText));
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
const { view, changed } = unwrap(body);
|
|
893
|
+
let showRaw = false;
|
|
894
|
+
let pre = renderJson(view);
|
|
895
|
+
container.append(pre);
|
|
896
|
+
if (!changed) return;
|
|
897
|
+
const toggle = el("button", "btn-low cnt", "RAW");
|
|
898
|
+
toggle.addEventListener("click", () => {
|
|
899
|
+
showRaw = !showRaw;
|
|
900
|
+
toggle.textContent = showRaw ? "VIEW" : "RAW";
|
|
901
|
+
const next = renderJson(showRaw ? body : view);
|
|
902
|
+
pre.replaceWith(next);
|
|
903
|
+
pre = next;
|
|
904
|
+
});
|
|
905
|
+
wlabel.append(toggle);
|
|
906
|
+
}
|
|
907
|
+
function selectCall(callId) {
|
|
908
|
+
const previous = focusCallId;
|
|
909
|
+
focusCallId = callId;
|
|
910
|
+
if (servedSession) {
|
|
911
|
+
const url = new URL(location.href);
|
|
912
|
+
url.searchParams.set("call", callId);
|
|
913
|
+
history.replaceState(null, "", url);
|
|
914
|
+
}
|
|
915
|
+
updateCallSelection(previous);
|
|
916
|
+
threadView?.setFocus(previous, callId);
|
|
917
|
+
if (graphView && !replayTimer) setGraphFocus(graphView, callId);
|
|
918
|
+
void renderDetail();
|
|
919
|
+
document.querySelector(".col-side")?.scrollTo({ top: 0 });
|
|
920
|
+
}
|
|
921
|
+
function parseJsonl(text) {
|
|
922
|
+
return text.split("\n").filter(Boolean).map((line) => JSON.parse(line));
|
|
923
|
+
}
|
|
924
|
+
async function loadFile(file) {
|
|
925
|
+
const events = parseJsonl(await file.text());
|
|
926
|
+
const sessionId = events[0]?.sessionId ?? file.name.replace(/\.jsonl$/, "");
|
|
927
|
+
setModelFromEvents(sessionId, events);
|
|
928
|
+
$("#hdr-mode").textContent = `FILE \xB7 ${file.name}`;
|
|
929
|
+
}
|
|
930
|
+
function initFileMode() {
|
|
931
|
+
const zone = $("#drop-stage");
|
|
932
|
+
zone.hidden = false;
|
|
933
|
+
const input = $("#file-input");
|
|
934
|
+
zone.addEventListener("click", () => input.click());
|
|
935
|
+
input.addEventListener("change", () => {
|
|
936
|
+
if (input.files?.[0]) void loadFile(input.files[0]);
|
|
937
|
+
});
|
|
938
|
+
for (const eventName of ["dragover", "dragleave", "drop"]) {
|
|
939
|
+
zone.addEventListener(eventName, (event) => {
|
|
940
|
+
event.preventDefault();
|
|
941
|
+
zone.classList.toggle("over", eventName === "dragover");
|
|
942
|
+
if (eventName === "drop") {
|
|
943
|
+
const file = event.dataTransfer?.files?.[0];
|
|
944
|
+
if (file) void loadFile(file);
|
|
945
|
+
}
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
async function loadServed(sessionId) {
|
|
950
|
+
const response = await fetch(`/api/sessions/${sessionId}/events`);
|
|
951
|
+
const payload = await response.json();
|
|
952
|
+
setModelFromEvents(sessionId, payload.events);
|
|
953
|
+
$("#hdr-mode").textContent = `LIVE \xB7 ${sessionId.slice(0, 8)}`;
|
|
954
|
+
}
|
|
955
|
+
function initServedMode(sessionId) {
|
|
956
|
+
void loadServed(sessionId);
|
|
957
|
+
const source = new EventSource(`/api/sessions/${sessionId}/stream`);
|
|
958
|
+
let pending = false;
|
|
959
|
+
source.addEventListener("changed", () => {
|
|
960
|
+
if (pending) return;
|
|
961
|
+
pending = true;
|
|
962
|
+
setTimeout(() => {
|
|
963
|
+
pending = false;
|
|
964
|
+
void loadServed(sessionId);
|
|
965
|
+
}, 400);
|
|
966
|
+
});
|
|
967
|
+
setInterval(() => void loadServed(sessionId), 3e4);
|
|
968
|
+
setInterval(() => {
|
|
969
|
+
if (!model?.summary.running) return;
|
|
970
|
+
for (const { call, duration } of callRows.values()) {
|
|
971
|
+
if (call.outcome === "running") duration.textContent = fmtMs(Date.now() - new Date(call.startedAt).getTime());
|
|
972
|
+
}
|
|
973
|
+
}, 1e3);
|
|
974
|
+
}
|
|
975
|
+
var resizeTimer = null;
|
|
976
|
+
window.addEventListener("resize", () => {
|
|
977
|
+
if (resizeTimer) clearTimeout(resizeTimer);
|
|
978
|
+
resizeTimer = setTimeout(() => {
|
|
979
|
+
if (model) {
|
|
980
|
+
renderThreadPanel();
|
|
981
|
+
if (!replayTimer) renderGraphPanel();
|
|
982
|
+
}
|
|
983
|
+
}, 200);
|
|
984
|
+
});
|
|
985
|
+
if (servedSession) initServedMode(servedSession);
|
|
986
|
+
else initFileMode();
|
|
987
|
+
})();
|
|
988
|
+
|
|
989
|
+
</script>
|
|
990
|
+
</body>
|
|
991
|
+
</html>
|