@webda/debug 4.0.0-beta.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.
Files changed (66) hide show
  1. package/LICENSE +166 -0
  2. package/README.md +95 -0
  3. package/lib/bodycapture.d.ts +66 -0
  4. package/lib/bodycapture.js +162 -0
  5. package/lib/bodycapture.js.map +1 -0
  6. package/lib/debugservice.service.d.ts +166 -0
  7. package/lib/debugservice.service.js +609 -0
  8. package/lib/debugservice.service.js.map +1 -0
  9. package/lib/index.d.ts +7 -0
  10. package/lib/index.js +7 -0
  11. package/lib/index.js.map +1 -0
  12. package/lib/introspection.d.ts +115 -0
  13. package/lib/introspection.js +260 -0
  14. package/lib/introspection.js.map +1 -0
  15. package/lib/logbuffer.d.ts +75 -0
  16. package/lib/logbuffer.js +98 -0
  17. package/lib/logbuffer.js.map +1 -0
  18. package/lib/requestlog.d.ts +181 -0
  19. package/lib/requestlog.js +153 -0
  20. package/lib/requestlog.js.map +1 -0
  21. package/lib/tui/client.d.ts +165 -0
  22. package/lib/tui/client.js +220 -0
  23. package/lib/tui/client.js.map +1 -0
  24. package/lib/tui/panels/config.d.ts +79 -0
  25. package/lib/tui/panels/config.js +291 -0
  26. package/lib/tui/panels/config.js.map +1 -0
  27. package/lib/tui/panels/logs.d.ts +106 -0
  28. package/lib/tui/panels/logs.js +342 -0
  29. package/lib/tui/panels/logs.js.map +1 -0
  30. package/lib/tui/panels/models.d.ts +47 -0
  31. package/lib/tui/panels/models.js +193 -0
  32. package/lib/tui/panels/models.js.map +1 -0
  33. package/lib/tui/panels/operations.d.ts +55 -0
  34. package/lib/tui/panels/operations.js +241 -0
  35. package/lib/tui/panels/operations.js.map +1 -0
  36. package/lib/tui/panels/panel.d.ts +55 -0
  37. package/lib/tui/panels/panel.js +31 -0
  38. package/lib/tui/panels/panel.js.map +1 -0
  39. package/lib/tui/panels/requests.d.ts +156 -0
  40. package/lib/tui/panels/requests.js +512 -0
  41. package/lib/tui/panels/requests.js.map +1 -0
  42. package/lib/tui/panels/routes.d.ts +45 -0
  43. package/lib/tui/panels/routes.js +154 -0
  44. package/lib/tui/panels/routes.js.map +1 -0
  45. package/lib/tui/panels/services.d.ts +49 -0
  46. package/lib/tui/panels/services.js +266 -0
  47. package/lib/tui/panels/services.js.map +1 -0
  48. package/lib/tui/tui.d.ts +58 -0
  49. package/lib/tui/tui.js +236 -0
  50. package/lib/tui/tui.js.map +1 -0
  51. package/package.json +60 -0
  52. package/webui/app.js +177 -0
  53. package/webui/components/logs.js +128 -0
  54. package/webui/components/model-graph.js +292 -0
  55. package/webui/components/models.js +213 -0
  56. package/webui/components/operations.js +315 -0
  57. package/webui/components/requests.js +264 -0
  58. package/webui/components/schema-form.js +268 -0
  59. package/webui/components/services.js +248 -0
  60. package/webui/index.html +22 -0
  61. package/webui/logo.svg +1 -0
  62. package/webui/styles.css +685 -0
  63. package/webui/vendor/htm.js +1 -0
  64. package/webui/vendor/preact-hooks.js +2 -0
  65. package/webui/vendor/preact.js +2 -0
  66. package/webui/webda.png +0 -0
@@ -0,0 +1,315 @@
1
+ import { h } from "preact";
2
+ import { useState, useMemo } from "preact/hooks";
3
+ import htm from "htm";
4
+ import { SchemaForm } from "./schema-form.js";
5
+
6
+ const html = htm.bind(h);
7
+
8
+ /**
9
+ * Generate a random example value from a JSON Schema property.
10
+ */
11
+ function randomValue(schema, name) {
12
+ if (!schema) return null;
13
+ if (schema.enum) return schema.enum[Math.floor(Math.random() * schema.enum.length)];
14
+ if (schema.const) return schema.const;
15
+ switch (schema.type) {
16
+ case "string":
17
+ if (schema.format === "email") return "user@example.com";
18
+ if (schema.format === "uri" || schema.format === "url") return "https://example.com";
19
+ if (schema.format === "date-time") return new Date().toISOString();
20
+ if (schema.format === "date") return new Date().toISOString().split("T")[0];
21
+ if (schema.format === "uuid") return crypto.randomUUID();
22
+ if (schema.pattern) return `match-${name || "value"}`;
23
+ if (schema.minLength) return (name || "text").padEnd(schema.minLength, "x");
24
+ return name || "string-value";
25
+ case "number":
26
+ case "integer":
27
+ const min = schema.minimum ?? 0;
28
+ const max = schema.maximum ?? 100;
29
+ return schema.type === "integer" ? Math.floor(Math.random() * (max - min) + min) : +(Math.random() * (max - min) + min).toFixed(2);
30
+ case "boolean":
31
+ return Math.random() > 0.5;
32
+ case "array":
33
+ const item = schema.items ? randomValue(schema.items, name) : "item";
34
+ return [item, randomValue(schema.items, name)].filter(Boolean);
35
+ case "object":
36
+ if (!schema.properties) return {};
37
+ const obj = {};
38
+ for (const [k, v] of Object.entries(schema.properties)) {
39
+ obj[k] = randomValue(v, k);
40
+ }
41
+ return obj;
42
+ default:
43
+ return null;
44
+ }
45
+ }
46
+
47
+ export function OperationsPanel({ data }) {
48
+ const [filter, setFilter] = useState("");
49
+ const [selected, setSelected] = useState(null);
50
+ const [activeTab, setActiveTab] = useState("form");
51
+ const [formValues, setFormValues] = useState({});
52
+ const operations = data || [];
53
+
54
+ const filtered = operations.filter(
55
+ (o) => (o.id || "").toLowerCase().includes(filter.toLowerCase())
56
+ );
57
+
58
+ const detail = selected ? operations.find(o => o.id === selected) : null;
59
+
60
+ const exampleOutput = useMemo(() => {
61
+ if (!detail?.outputSchema) return null;
62
+ return randomValue(detail.outputSchema, "result");
63
+ }, [detail?.id, detail?.outputSchema]);
64
+
65
+ return html`
66
+ <div class="split-panel">
67
+ <div class="split-left">
68
+ <input
69
+ class="search-input"
70
+ placeholder="Filter operations..."
71
+ value=${filter}
72
+ onInput=${(e) => setFilter(e.target.value)}
73
+ />
74
+ ${filtered.map(o => html`
75
+ <div
76
+ key=${o.id}
77
+ class="list-item ${selected === o.id ? "active" : ""}"
78
+ onClick=${() => { setSelected(o.id); setFormValues({}); setActiveTab("form"); }}
79
+ >
80
+ <div>${o.id}</div>
81
+ <div style="font-size:0.6875rem;color:var(--text-muted)">
82
+ ${o.input && o.input !== "void" ? o.input.split("/").pop().split(".").pop() : "void"}
83
+ ${" → "}
84
+ ${o.output && o.output !== "void" ? o.output.split("/").pop().split(".").pop() : "void"}
85
+ </div>
86
+ ${o.rest?.method && html`
87
+ <div style="font-size:0.625rem;margin-top:0.125rem;display:flex;align-items:center;gap:0.25rem;min-width:0">
88
+ <span class="badge method-${o.rest.method.toLowerCase()}" style="font-size:0.5625rem;padding:0.0625rem 0.25rem;flex-shrink:0">${o.rest.method.toUpperCase()}</span>
89
+ <span class="mono" style="color:var(--text-muted);opacity:0.7;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${o.rest.url || o.rest.path || "/"}</span>
90
+ </div>
91
+ `}
92
+ </div>
93
+ `)}
94
+ ${filtered.length === 0 && html`<div style="color:var(--text-muted);padding:0.5rem">No operations found</div>`}
95
+ </div>
96
+ <div class="split-right">
97
+ ${detail ? html`<${OperationDetail}
98
+ op=${detail}
99
+ activeTab=${activeTab}
100
+ setActiveTab=${setActiveTab}
101
+ formValues=${formValues}
102
+ setFormValues=${setFormValues}
103
+ exampleOutput=${exampleOutput}
104
+ />` : html`
105
+ <div style="color:var(--text-muted);padding:2rem;text-align:center">
106
+ Select an operation to view details
107
+ </div>
108
+ `}
109
+ </div>
110
+ </div>
111
+ `;
112
+ }
113
+
114
+ /**
115
+ * Lightweight JS syntax highlighter — no external dependencies.
116
+ * Tokenizes into comments, strings, keywords, numbers, and punctuation.
117
+ */
118
+ function highlightJS(code) {
119
+ const keywords = new Set([
120
+ "async","await","break","case","catch","class","const","continue","debugger","default",
121
+ "delete","do","else","export","extends","finally","for","function","if","import","in",
122
+ "instanceof","let","new","of","return","static","super","switch","this","throw","try",
123
+ "typeof","var","void","while","with","yield","from","true","false","null","undefined"
124
+ ]);
125
+ const tokens = [];
126
+ let i = 0;
127
+ while (i < code.length) {
128
+ // Line comments
129
+ if (code[i] === "/" && code[i + 1] === "/") {
130
+ const end = code.indexOf("\n", i);
131
+ tokens.push({ type: "comment", value: code.slice(i, end === -1 ? undefined : end) });
132
+ i = end === -1 ? code.length : end;
133
+ continue;
134
+ }
135
+ // Block comments
136
+ if (code[i] === "/" && code[i + 1] === "*") {
137
+ const end = code.indexOf("*/", i + 2);
138
+ tokens.push({ type: "comment", value: code.slice(i, end === -1 ? undefined : end + 2) });
139
+ i = end === -1 ? code.length : end + 2;
140
+ continue;
141
+ }
142
+ // Strings
143
+ if (code[i] === '"' || code[i] === "'" || code[i] === "`") {
144
+ const q = code[i];
145
+ let j = i + 1;
146
+ while (j < code.length && code[j] !== q) { if (code[j] === "\\") j++; j++; }
147
+ tokens.push({ type: "string", value: code.slice(i, j + 1) });
148
+ i = j + 1;
149
+ continue;
150
+ }
151
+ // Numbers
152
+ if (/[0-9]/.test(code[i]) && (i === 0 || /[^a-zA-Z_$]/.test(code[i - 1]))) {
153
+ let j = i;
154
+ while (j < code.length && /[0-9a-fA-FxXoObBeE._n]/.test(code[j])) j++;
155
+ tokens.push({ type: "number", value: code.slice(i, j) });
156
+ i = j;
157
+ continue;
158
+ }
159
+ // Words (identifiers / keywords)
160
+ if (/[a-zA-Z_$]/.test(code[i])) {
161
+ let j = i;
162
+ while (j < code.length && /[a-zA-Z0-9_$]/.test(code[j])) j++;
163
+ const word = code.slice(i, j);
164
+ tokens.push({ type: keywords.has(word) ? "keyword" : "ident", value: word });
165
+ i = j;
166
+ continue;
167
+ }
168
+ // Punctuation / operators
169
+ tokens.push({ type: "punct", value: code[i] });
170
+ i++;
171
+ }
172
+ return tokens;
173
+ }
174
+
175
+ function CodeBlock({ code }) {
176
+ const tokens = useMemo(() => highlightJS(code), [code]);
177
+ return html`<pre class="code-block"><code>${tokens.map((t, i) =>
178
+ t.type === "comment" ? html`<span key=${i} class="hl-comment">${t.value}</span>`
179
+ : t.type === "string" ? html`<span key=${i} class="hl-string">${t.value}</span>`
180
+ : t.type === "keyword" ? html`<span key=${i} class="hl-keyword">${t.value}</span>`
181
+ : t.type === "number" ? html`<span key=${i} class="hl-number">${t.value}</span>`
182
+ : t.value
183
+ )}</code></pre>`;
184
+ }
185
+
186
+ function OperationDetail({ op, activeTab, setActiveTab, formValues, setFormValues, exampleOutput }) {
187
+ const tabs = [];
188
+ if (op.inputSchema) tabs.push("form");
189
+ tabs.push("input-schema", "output-schema");
190
+ if (exampleOutput !== null) tabs.push("example");
191
+ if (op.implementor?.code) tabs.push("code");
192
+
193
+ const currentTab = tabs.includes(activeTab) ? activeTab : tabs[0];
194
+
195
+ const hasInput = op.input && op.input !== "void";
196
+ const hasOutput = op.output && op.output !== "void";
197
+
198
+ return html`
199
+ <div>
200
+ <h2 style="margin-bottom:0.5rem;color:var(--accent)">${op.id}</h2>
201
+
202
+ <div style="display:flex;gap:0.75rem;margin-bottom:1rem;flex-wrap:wrap">
203
+ ${op.summary && html`<div style="color:var(--text-muted);font-size:0.875rem">${op.summary}</div>`}
204
+ </div>
205
+
206
+ <div style="display:flex;gap:0.5rem;margin-bottom:0.75rem;flex-wrap:wrap;align-items:center">
207
+ <div style="background:var(--bg-tertiary);padding:0.375rem 0.625rem;border-radius:4px;font-size:0.8125rem">
208
+ <span style="color:var(--text-muted)">Input: </span>
209
+ <span class="mono">${hasInput ? op.input : "void"}</span>
210
+ </div>
211
+ <div style="background:var(--bg-tertiary);padding:0.375rem 0.625rem;border-radius:4px;font-size:0.8125rem">
212
+ <span style="color:var(--text-muted)">Output: </span>
213
+ <span class="mono">${hasOutput ? op.output : "void"}</span>
214
+ </div>
215
+ ${op.implementor && html`
216
+ <div style="background:var(--bg-tertiary);padding:0.375rem 0.625rem;border-radius:4px;font-size:0.8125rem">
217
+ <span style="color:var(--text-muted)">${op.implementor.type === "model" ? "Model" : "Service"}: </span>
218
+ <span class="mono">${op.implementor.name}</span>
219
+ ${op.implementor.method && html`<span class="mono" style="color:var(--text-muted)">.${op.implementor.method}()</span>`}
220
+ </div>
221
+ `}
222
+ ${op.tags?.length > 0 && op.tags.map(t => html`
223
+ <span key=${t} class="badge badge-purple">${t}</span>
224
+ `)}
225
+ ${op.rest && typeof op.rest === "object" && op.rest.method && html`
226
+ <span class="badge method-${op.rest.method.toLowerCase()}" style="padding:0.125rem 0.5rem;line-height:1.2">${op.rest.method.toUpperCase()}</span>
227
+ <span class="mono" style="font-size:0.8125rem;color:var(--text-muted)">${op.rest.url || op.rest.path || "/"}</span>
228
+ `}
229
+ </div>
230
+
231
+ <!-- Tabs -->
232
+ <div style="display:flex;gap:0;margin-bottom:0.75rem;border-bottom:1px solid var(--border)">
233
+ ${tabs.map(tab => {
234
+ const label = tab === "form" ? "Try It" : tab === "input-schema" ? "Input Schema" : tab === "output-schema" ? "Output Schema" : tab === "code" ? "Code" : "Example Output";
235
+ return html`
236
+ <button key=${tab} onClick=${() => setActiveTab(tab)} style="
237
+ padding:6px 16px;
238
+ background:${currentTab === tab ? "var(--bg-tertiary)" : "transparent"};
239
+ color:${currentTab === tab ? "var(--accent)" : "var(--text-muted)"};
240
+ border:none;
241
+ border-bottom:2px solid ${currentTab === tab ? "var(--accent)" : "transparent"};
242
+ cursor:pointer;font-size:0.875rem;
243
+ font-weight:${currentTab === tab ? "600" : "400"};
244
+ ">${label}</button>
245
+ `;
246
+ })}
247
+ </div>
248
+
249
+ <!-- Tab Content -->
250
+ ${currentTab === "form" && op.inputSchema && html`
251
+ <div>
252
+ <${SchemaForm} schema=${op.inputSchema} values=${formValues} onChange=${setFormValues} />
253
+ <div style="margin-top:1rem">
254
+ <button class="btn btn-primary" disabled>Execute (coming soon)</button>
255
+ </div>
256
+ ${Object.keys(formValues).length > 0 && html`
257
+ <div style="margin-top:0.75rem">
258
+ <strong style="color:var(--text-muted);font-size:0.75rem;text-transform:uppercase">Request Body</strong>
259
+ <pre class="mono" style="
260
+ background:var(--bg-secondary);padding:0.75rem;border-radius:4px;
261
+ margin-top:0.25rem;font-size:0.8125rem;overflow:auto;max-height:200px;
262
+ ">${JSON.stringify(formValues, null, 2)}</pre>
263
+ </div>
264
+ `}
265
+ </div>
266
+ `}
267
+
268
+ ${currentTab === "input-schema" && html`
269
+ <div>
270
+ ${hasInput && op.inputSchema
271
+ ? html`<pre class="mono" style="
272
+ background:var(--bg-secondary);padding:1rem;border-radius:4px;
273
+ overflow:auto;max-height:400px;font-size:0.8125rem;line-height:1.5;
274
+ ">${JSON.stringify(op.inputSchema, null, 2)}</pre>`
275
+ : html`<div style="color:var(--text-muted);padding:0.5rem">${hasInput ? `Schema ref: ${op.input} (not resolved)` : "No input (void)"}</div>`
276
+ }
277
+ </div>
278
+ `}
279
+
280
+ ${currentTab === "output-schema" && html`
281
+ <div>
282
+ ${hasOutput && op.outputSchema
283
+ ? html`<pre class="mono" style="
284
+ background:var(--bg-secondary);padding:1rem;border-radius:4px;
285
+ overflow:auto;max-height:400px;font-size:0.8125rem;line-height:1.5;
286
+ ">${JSON.stringify(op.outputSchema, null, 2)}</pre>`
287
+ : html`<div style="color:var(--text-muted);padding:0.5rem">${hasOutput ? `Schema ref: ${op.output} (not resolved)` : "No output (void)"}</div>`
288
+ }
289
+ </div>
290
+ `}
291
+
292
+ ${currentTab === "example" && html`
293
+ <div>
294
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.5rem">
295
+ <strong style="color:var(--text-muted);font-size:0.75rem;text-transform:uppercase">Random Example</strong>
296
+ <button class="btn btn-ghost btn-sm" onClick=${() => { /* force re-render */ setActiveTab("input-schema"); setTimeout(() => setActiveTab("example"), 0); }}>Regenerate</button>
297
+ </div>
298
+ <pre class="mono" style="
299
+ background:var(--bg-secondary);padding:1rem;border-radius:4px;
300
+ overflow:auto;max-height:400px;font-size:0.8125rem;line-height:1.5;
301
+ ">${JSON.stringify(exampleOutput, null, 2)}</pre>
302
+ </div>
303
+ `}
304
+
305
+ ${currentTab === "code" && op.implementor?.code && html`
306
+ <div>
307
+ <div style="margin-bottom:0.5rem;font-size:0.8125rem;color:var(--text-muted)">
308
+ <span class="mono">${op.implementor.name}.${op.implementor.method}()</span>
309
+ </div>
310
+ <${CodeBlock} code=${op.implementor.code} />
311
+ </div>
312
+ `}
313
+ </div>
314
+ `;
315
+ }
@@ -0,0 +1,264 @@
1
+ import { h } from "preact";
2
+ import { useEffect, useMemo, useState } from "preact/hooks";
3
+ import htm from "htm";
4
+
5
+ const html = htm.bind(h);
6
+
7
+ function statusClass(code) {
8
+ if (!code) return "status-pending";
9
+ if (code < 300) return "status-2xx";
10
+ if (code < 400) return "status-3xx";
11
+ if (code < 500) return "status-4xx";
12
+ return "status-5xx";
13
+ }
14
+
15
+ function methodBadge(method) {
16
+ if (!method) return "";
17
+ const m = method.toUpperCase();
18
+ return html`<span class="badge method-${m.toLowerCase()}">${m}</span>`;
19
+ }
20
+
21
+ function formatTime(ts) {
22
+ if (!ts) return "-";
23
+ const d = new Date(ts);
24
+ return d.toLocaleTimeString();
25
+ }
26
+
27
+ function formatBytes(n) {
28
+ if (n == null) return "-";
29
+ if (n < 1024) return `${n} B`;
30
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
31
+ return `${(n / (1024 * 1024)).toFixed(2)} MB`;
32
+ }
33
+
34
+ /**
35
+ * Render a request/response body in the most useful format we can:
36
+ * - empty -> grey placeholder
37
+ * - text -> preformatted block (pretty-printed JSON when possible)
38
+ * - text-truncated -> same plus a [truncated] notice
39
+ * - binary -> "Binary, N bytes (preview: 0xHEX)"
40
+ */
41
+ function BodyView({ body }) {
42
+ if (!body) return html`<div style="color:var(--text-muted);font-style:italic">(not captured)</div>`;
43
+ if (body.kind === "empty") {
44
+ return html`<div style="color:var(--text-muted);font-style:italic">(empty)</div>`;
45
+ }
46
+ if (body.kind === "binary") {
47
+ return html`
48
+ <div style="color:var(--text-muted);font-family:monospace;font-size:13px">
49
+ Binary, ${formatBytes(body.size)} (preview: 0x${body.preview || ""})
50
+ </div>
51
+ `;
52
+ }
53
+ // text or text-truncated
54
+ let display = body.content || "";
55
+ // Try to pretty-print JSON
56
+ try {
57
+ const parsed = JSON.parse(display);
58
+ display = JSON.stringify(parsed, null, 2);
59
+ } catch {
60
+ // not JSON — leave as-is
61
+ }
62
+ return html`
63
+ <pre class="mono" style="background:var(--bg-secondary);padding:0.75rem;border-radius:4px;overflow:auto;max-height:400px;font-size:12px;white-space:pre-wrap;word-break:break-all">${display}</pre>
64
+ ${body.kind === "text-truncated"
65
+ ? html`<div style="color:var(--text-muted);font-size:12px;margin-top:0.25rem">[truncated — total ${formatBytes(body.size)}]</div>`
66
+ : ""}
67
+ `;
68
+ }
69
+
70
+ /**
71
+ * Render a flat list of headers as a small two-column table.
72
+ */
73
+ function HeadersView({ headers }) {
74
+ const keys = headers ? Object.keys(headers) : [];
75
+ if (keys.length === 0) {
76
+ return html`<div style="color:var(--text-muted);font-style:italic">(none)</div>`;
77
+ }
78
+ return html`
79
+ <table style="width:100%;font-size:12px">
80
+ <tbody>
81
+ ${keys.sort().map(
82
+ k => html`
83
+ <tr key=${k}>
84
+ <td class="mono" style="color:var(--text-muted);padding:2px 8px 2px 0;vertical-align:top;white-space:nowrap">${k}</td>
85
+ <td class="mono" style="word-break:break-all">${headers[k]}</td>
86
+ </tr>
87
+ `
88
+ )}
89
+ </tbody>
90
+ </table>
91
+ `;
92
+ }
93
+
94
+ /**
95
+ * Detail panel for a single request: status line, headers, bodies, error.
96
+ * Loads the full entry from /api/requests/:id when `id` changes.
97
+ */
98
+ function RequestDetail({ id, onClose }) {
99
+ const [entry, setEntry] = useState(null);
100
+ const [error, setError] = useState(null);
101
+
102
+ useEffect(() => {
103
+ if (!id) {
104
+ setEntry(null);
105
+ return;
106
+ }
107
+ let cancelled = false;
108
+ setError(null);
109
+ fetch(`/api/requests/${encodeURIComponent(id)}`)
110
+ .then(r => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
111
+ .then(data => {
112
+ if (!cancelled) setEntry(data);
113
+ })
114
+ .catch(err => {
115
+ if (!cancelled) setError(err.message);
116
+ });
117
+ return () => {
118
+ cancelled = true;
119
+ };
120
+ }, [id]);
121
+
122
+ if (!id) return null;
123
+ if (error) {
124
+ return html`
125
+ <div style="padding:1rem;border:1px solid var(--border);border-radius:4px;margin-top:1rem;background:var(--bg-secondary)">
126
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.5rem">
127
+ <strong>Failed to load request detail</strong>
128
+ <button onClick=${onClose} style="background:transparent;border:1px solid var(--border);color:var(--text-primary);padding:2px 8px;border-radius:4px;cursor:pointer">Close</button>
129
+ </div>
130
+ <div style="color:var(--text-muted)">${error}</div>
131
+ </div>
132
+ `;
133
+ }
134
+ if (!entry) {
135
+ return html`
136
+ <div style="padding:1rem;color:var(--text-muted)">Loading request detail...</div>
137
+ `;
138
+ }
139
+ return html`
140
+ <div style="padding:1rem;border:1px solid var(--border);border-radius:4px;margin-top:1rem;background:var(--bg-secondary)">
141
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.75rem">
142
+ <div>
143
+ ${methodBadge(entry.method)}
144
+ <span class="mono" style="margin-left:0.5rem">${entry.url}</span>
145
+ </div>
146
+ <button onClick=${onClose} style="background:transparent;border:1px solid var(--border);color:var(--text-primary);padding:2px 8px;border-radius:4px;cursor:pointer">Close</button>
147
+ </div>
148
+ <div style="display:flex;gap:1.5rem;font-size:12px;color:var(--text-muted);margin-bottom:1rem">
149
+ <div>Time: ${formatTime(entry.timestamp)}</div>
150
+ <div>Status: <span class="mono ${statusClass(entry.statusCode)}" style="font-weight:600">${entry.statusCode != null ? entry.statusCode : "pending"}</span></div>
151
+ <div>Duration: ${entry.duration != null ? `${entry.duration}ms` : "-"}</div>
152
+ </div>
153
+
154
+ ${entry.error
155
+ ? html`
156
+ <div style="margin-bottom:1rem;padding:0.5rem 0.75rem;background:rgba(239,68,68,0.1);border:1px solid rgba(239,68,68,0.3);border-radius:4px">
157
+ <div style="color:#ef4444;font-weight:600;margin-bottom:0.25rem">Error</div>
158
+ <div class="mono" style="font-size:12px">${entry.error.message}</div>
159
+ ${entry.error.stack
160
+ ? html`<pre class="mono" style="font-size:11px;color:var(--text-muted);margin-top:0.5rem;white-space:pre-wrap">${entry.error.stack}</pre>`
161
+ : ""}
162
+ </div>
163
+ `
164
+ : ""}
165
+
166
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
167
+ <div>
168
+ <h4 style="margin:0 0 0.5rem 0;font-size:13px">Request Headers</h4>
169
+ <${HeadersView} headers=${entry.requestHeaders} />
170
+ <h4 style="margin:1rem 0 0.5rem 0;font-size:13px">Request Body</h4>
171
+ <${BodyView} body=${entry.requestBody} />
172
+ </div>
173
+ <div>
174
+ <h4 style="margin:0 0 0.5rem 0;font-size:13px">Response Headers</h4>
175
+ <${HeadersView} headers=${entry.responseHeaders} />
176
+ <h4 style="margin:1rem 0 0.5rem 0;font-size:13px">Response Body</h4>
177
+ <${BodyView} body=${entry.responseBody} />
178
+ </div>
179
+ </div>
180
+ </div>
181
+ `;
182
+ }
183
+
184
+ export function RequestsPanel({ data, wsEvents }) {
185
+ const historical = data || [];
186
+ const [selectedId, setSelectedId] = useState(null);
187
+
188
+ // Merge websocket events with historical data
189
+ // wsEvents are newest-first; historical may be oldest-first or newest-first
190
+ const entries = useMemo(() => {
191
+ // Build a map of all requests by ID
192
+ const map = new Map();
193
+
194
+ // Add historical entries
195
+ for (const entry of historical) {
196
+ if (entry.id) map.set(entry.id, { ...entry });
197
+ }
198
+
199
+ // Apply websocket events (they update existing or add new)
200
+ for (const evt of [...wsEvents].reverse()) {
201
+ if (!evt.id) continue;
202
+ const existing = map.get(evt.id) || {};
203
+
204
+ if (evt.type === "request") {
205
+ map.set(evt.id, { ...existing, id: evt.id, method: evt.method, url: evt.url, timestamp: evt.timestamp });
206
+ } else if (evt.type === "result") {
207
+ map.set(evt.id, { ...existing, id: evt.id, statusCode: evt.statusCode, duration: evt.duration });
208
+ } else if (evt.type === "404") {
209
+ map.set(evt.id, { ...existing, id: evt.id, method: evt.method, url: evt.url, statusCode: 404 });
210
+ }
211
+ }
212
+
213
+ // Sort by timestamp, newest first
214
+ return Array.from(map.values()).sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0));
215
+ }, [historical, wsEvents]);
216
+
217
+ return html`
218
+ <div>
219
+ <div style="margin-bottom: 0.75rem; color: var(--text-muted); font-size: 0.8125rem;">
220
+ ${entries.length} request${entries.length !== 1 ? "s" : ""} recorded${selectedId ? " — click a row to inspect" : ""}
221
+ </div>
222
+ <div class="table-container">
223
+ <table>
224
+ <thead>
225
+ <tr>
226
+ <th>Time</th>
227
+ <th>Method</th>
228
+ <th>URL</th>
229
+ <th>Status</th>
230
+ <th>Duration</th>
231
+ </tr>
232
+ </thead>
233
+ <tbody>
234
+ ${entries.map(
235
+ (r) => html`
236
+ <tr
237
+ key=${r.id}
238
+ onClick=${() => setSelectedId(r.id === selectedId ? null : r.id)}
239
+ style="cursor:pointer;${r.id === selectedId ? "background:var(--bg-tertiary)" : ""}"
240
+ >
241
+ <td style="white-space: nowrap;">${formatTime(r.timestamp)}</td>
242
+ <td>${methodBadge(r.method)}</td>
243
+ <td class="mono" style="max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${r.url || "-"}</td>
244
+ <td>
245
+ ${r.statusCode != null
246
+ ? html`<span class="mono ${statusClass(r.statusCode)}" style="font-weight: 600;">${r.statusCode}</span>`
247
+ : html`<span class="status-pending">pending...</span>`}
248
+ </td>
249
+ <td class="mono" style="color: var(--text-muted);">
250
+ ${r.duration != null ? `${r.duration}ms` : "-"}
251
+ </td>
252
+ </tr>
253
+ `
254
+ )}
255
+ ${entries.length === 0 && html`
256
+ <tr><td colspan="5" style="color: var(--text-muted); text-align: center; padding: 2rem;">No requests yet. Requests will appear here in real-time.</td></tr>
257
+ `}
258
+ </tbody>
259
+ </table>
260
+ </div>
261
+ ${selectedId ? html`<${RequestDetail} id=${selectedId} onClose=${() => setSelectedId(null)} />` : ""}
262
+ </div>
263
+ `;
264
+ }