@stainlessdev/docs-xray 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 +201 -0
- package/README.md +60 -0
- package/package.json +32 -0
- package/public/xray-badge.js +179 -0
- package/src/astro/XRayPageSetup.astro +57 -0
- package/src/astro/index.ts +1 -0
- package/src/components/XRayPage.tsx +1243 -0
- package/src/components/XRayTabBadge.tsx +219 -0
- package/src/components/index.ts +5 -0
- package/src/index.ts +49 -0
- package/src/integration.ts +53 -0
- package/src/styles/xray.css +1032 -0
- package/src/types.ts +56 -0
- package/src/utils/index.ts +218 -0
- package/src/utils/spec.ts +120 -0
|
@@ -0,0 +1,1243 @@
|
|
|
1
|
+
import { useState, useEffect, useRef, useMemo, type MouseEvent } from "react";
|
|
2
|
+
import {
|
|
3
|
+
createHighlighter,
|
|
4
|
+
type HighlighterGeneric,
|
|
5
|
+
type BundledLanguage,
|
|
6
|
+
type BundledTheme,
|
|
7
|
+
} from "shiki";
|
|
8
|
+
import {
|
|
9
|
+
TypescriptIcon,
|
|
10
|
+
PythonIcon,
|
|
11
|
+
GoIcon,
|
|
12
|
+
JavaIcon,
|
|
13
|
+
KotlinIcon,
|
|
14
|
+
RubyIcon,
|
|
15
|
+
CSharpIcon,
|
|
16
|
+
} from "@stainless-api/docs-ui/components/icons";
|
|
17
|
+
|
|
18
|
+
import type {
|
|
19
|
+
XrayRequestLogDetail,
|
|
20
|
+
DetailTab,
|
|
21
|
+
EndpointMap,
|
|
22
|
+
ParsedUserAgent,
|
|
23
|
+
} from "../types";
|
|
24
|
+
import {
|
|
25
|
+
parseUserAgent,
|
|
26
|
+
formatUserAgentSummary,
|
|
27
|
+
getEndpointInfo,
|
|
28
|
+
getStatusText,
|
|
29
|
+
getStatusClass,
|
|
30
|
+
formatRelativeTime,
|
|
31
|
+
formatTime,
|
|
32
|
+
formatDate,
|
|
33
|
+
saveSeenIdsToStorage,
|
|
34
|
+
savePendingCountToStorage,
|
|
35
|
+
} from "../utils";
|
|
36
|
+
|
|
37
|
+
const POLL_INTERVAL = 2000;
|
|
38
|
+
|
|
39
|
+
type FetchResult = {
|
|
40
|
+
data: XrayRequestLogDetail[];
|
|
41
|
+
status: "ok" | "unauthorized" | "error";
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
async function fetchRecentRequests(xrayApi: string): Promise<FetchResult> {
|
|
45
|
+
try {
|
|
46
|
+
const response = await fetch(`${xrayApi}/v1/request_logs`, {
|
|
47
|
+
credentials: "include",
|
|
48
|
+
});
|
|
49
|
+
if (response.status === 401) {
|
|
50
|
+
return { data: [], status: "unauthorized" };
|
|
51
|
+
}
|
|
52
|
+
if (!response.ok) {
|
|
53
|
+
return { data: [], status: "error" };
|
|
54
|
+
}
|
|
55
|
+
const json = await response.json();
|
|
56
|
+
return { data: json.data ?? [], status: "ok" };
|
|
57
|
+
} catch {
|
|
58
|
+
return { data: [], status: "error" };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function fetchRequestLogDetail(
|
|
63
|
+
xrayApi: string,
|
|
64
|
+
requestId: string,
|
|
65
|
+
): Promise<XrayRequestLogDetail | null> {
|
|
66
|
+
try {
|
|
67
|
+
const response = await fetch(`${xrayApi}/v1/request_logs/${requestId}`, {
|
|
68
|
+
credentials: "include",
|
|
69
|
+
});
|
|
70
|
+
if (!response.ok) return null;
|
|
71
|
+
return await response.json();
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
type SessionUser = {
|
|
78
|
+
user: {
|
|
79
|
+
name: string;
|
|
80
|
+
ip: string;
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
type SessionResult = {
|
|
85
|
+
data: SessionUser | null;
|
|
86
|
+
status: "ok" | "unauthorized" | "error";
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
async function fetchSessionUser(xrayApi: string): Promise<SessionResult> {
|
|
90
|
+
try {
|
|
91
|
+
const response = await fetch(`${xrayApi}/api/docs-session-user`, {
|
|
92
|
+
credentials: "include",
|
|
93
|
+
});
|
|
94
|
+
if (response.status === 401) {
|
|
95
|
+
return { data: null, status: "unauthorized" };
|
|
96
|
+
}
|
|
97
|
+
if (!response.ok) {
|
|
98
|
+
return { data: null, status: "error" };
|
|
99
|
+
}
|
|
100
|
+
const json = await response.json();
|
|
101
|
+
return { data: json, status: "ok" };
|
|
102
|
+
} catch {
|
|
103
|
+
return { data: null, status: "error" };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Shiki highlighter with same theme as docs
|
|
108
|
+
const stainlessDocsJsonTheme = {
|
|
109
|
+
name: "stainless-docs-json",
|
|
110
|
+
colors: {
|
|
111
|
+
"editor.background": "var(--stl-color-background)",
|
|
112
|
+
"editor.foreground": "var(--stl-color-foreground)",
|
|
113
|
+
},
|
|
114
|
+
tokenColors: [
|
|
115
|
+
{
|
|
116
|
+
scope: ["comment", "punctuation.definition.comment"],
|
|
117
|
+
settings: { foreground: "var(--stl-color-foreground-muted)" },
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
scope: ["constant.numeric", "constant.language"],
|
|
121
|
+
settings: { foreground: "var(--stl-color-orange-foreground)" },
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
scope: ["string", "string.quoted", "string.template"],
|
|
125
|
+
settings: { foreground: "var(--stl-color-green-foreground)" },
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
scope: ["support.type", "meta"],
|
|
129
|
+
settings: { foreground: "var(--stl-color-foreground)" },
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
scope: ["meta"],
|
|
133
|
+
settings: { foreground: "var(--stl-color-foreground-muted)" },
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
scope: ["support.type.builtin"],
|
|
137
|
+
settings: { foreground: "var(--stl-color-purple-foreground)" },
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
} as const;
|
|
141
|
+
|
|
142
|
+
let highlighterPromise: Promise<
|
|
143
|
+
HighlighterGeneric<BundledLanguage, BundledTheme>
|
|
144
|
+
> | null = null;
|
|
145
|
+
|
|
146
|
+
function getHighlighter() {
|
|
147
|
+
if (!highlighterPromise) {
|
|
148
|
+
highlighterPromise = createHighlighter({
|
|
149
|
+
themes: [stainlessDocsJsonTheme],
|
|
150
|
+
langs: ["json"],
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
return highlighterPromise;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function formatJson(body: string): string {
|
|
157
|
+
try {
|
|
158
|
+
const parsed = JSON.parse(body);
|
|
159
|
+
return JSON.stringify(parsed, null, 2);
|
|
160
|
+
} catch {
|
|
161
|
+
return body;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function HighlightedJson({ body }: { body: string }) {
|
|
166
|
+
const [highlighted, setHighlighted] = useState<string>("");
|
|
167
|
+
const formatted = useMemo(() => formatJson(body), [body]);
|
|
168
|
+
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
let cancelled = false;
|
|
171
|
+
getHighlighter().then((highlighter) => {
|
|
172
|
+
if (cancelled) return;
|
|
173
|
+
const html = highlighter.codeToHtml(formatted, {
|
|
174
|
+
lang: "json",
|
|
175
|
+
themes: {
|
|
176
|
+
light: "stainless-docs-json",
|
|
177
|
+
dark: "stainless-docs-json",
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
setHighlighted(html);
|
|
181
|
+
});
|
|
182
|
+
return () => {
|
|
183
|
+
cancelled = true;
|
|
184
|
+
};
|
|
185
|
+
}, [formatted]);
|
|
186
|
+
|
|
187
|
+
if (!highlighted) {
|
|
188
|
+
return (
|
|
189
|
+
<pre className="xray-page__detail-code">
|
|
190
|
+
<code>{formatted}</code>
|
|
191
|
+
</pre>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return (
|
|
196
|
+
<div
|
|
197
|
+
className="xray-page__detail-code"
|
|
198
|
+
dangerouslySetInnerHTML={{ __html: highlighted }}
|
|
199
|
+
/>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function HeadersList({
|
|
204
|
+
headers,
|
|
205
|
+
}: {
|
|
206
|
+
headers: Record<string, string[]> | null;
|
|
207
|
+
}) {
|
|
208
|
+
if (!headers || Object.keys(headers).length === 0) {
|
|
209
|
+
return <div className="xray-page__detail-empty">No headers captured.</div>;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const canonicalizeHeaderName = (name: string) =>
|
|
213
|
+
name
|
|
214
|
+
.split("-")
|
|
215
|
+
.map((part) =>
|
|
216
|
+
part ? part[0].toUpperCase() + part.slice(1).toLowerCase() : part,
|
|
217
|
+
)
|
|
218
|
+
.join("-");
|
|
219
|
+
const entries = Object.entries(headers)
|
|
220
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
221
|
+
.map(([name, values]) => [canonicalizeHeaderName(name), values] as const);
|
|
222
|
+
|
|
223
|
+
return (
|
|
224
|
+
<div className="xray-page__headers">
|
|
225
|
+
{entries.map(([name, values]) => (
|
|
226
|
+
<div className="xray-page__header-row" key={name}>
|
|
227
|
+
<span className="xray-page__header-name">{name}</span>
|
|
228
|
+
<span className="xray-page__header-value">{values.join(", ")}</span>
|
|
229
|
+
</div>
|
|
230
|
+
))}
|
|
231
|
+
</div>
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Inline SVG icons
|
|
236
|
+
const TerminalIcon = () => (
|
|
237
|
+
<svg
|
|
238
|
+
width="12"
|
|
239
|
+
height="12"
|
|
240
|
+
viewBox="0 0 24 24"
|
|
241
|
+
fill="none"
|
|
242
|
+
stroke="currentColor"
|
|
243
|
+
strokeWidth="2"
|
|
244
|
+
strokeLinecap="round"
|
|
245
|
+
strokeLinejoin="round"
|
|
246
|
+
>
|
|
247
|
+
<polyline points="4 17 10 11 4 5" />
|
|
248
|
+
<line x1="12" y1="19" x2="20" y2="19" />
|
|
249
|
+
</svg>
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
const GlobeIcon = () => (
|
|
253
|
+
<svg
|
|
254
|
+
width="12"
|
|
255
|
+
height="12"
|
|
256
|
+
viewBox="0 0 24 24"
|
|
257
|
+
fill="none"
|
|
258
|
+
stroke="currentColor"
|
|
259
|
+
strokeWidth="2"
|
|
260
|
+
strokeLinecap="round"
|
|
261
|
+
strokeLinejoin="round"
|
|
262
|
+
>
|
|
263
|
+
<circle cx="12" cy="12" r="10" />
|
|
264
|
+
<line x1="2" y1="12" x2="22" y2="12" />
|
|
265
|
+
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
|
266
|
+
</svg>
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
const CopyIcon = () => (
|
|
270
|
+
<svg
|
|
271
|
+
width="12"
|
|
272
|
+
height="12"
|
|
273
|
+
viewBox="0 0 24 24"
|
|
274
|
+
fill="none"
|
|
275
|
+
stroke="currentColor"
|
|
276
|
+
strokeWidth="2"
|
|
277
|
+
strokeLinecap="round"
|
|
278
|
+
strokeLinejoin="round"
|
|
279
|
+
>
|
|
280
|
+
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
|
281
|
+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
|
282
|
+
</svg>
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
const PanelLeftIcon = () => (
|
|
286
|
+
<svg
|
|
287
|
+
width="12"
|
|
288
|
+
height="12"
|
|
289
|
+
viewBox="0 0 24 24"
|
|
290
|
+
fill="none"
|
|
291
|
+
stroke="currentColor"
|
|
292
|
+
strokeWidth="2"
|
|
293
|
+
strokeLinecap="round"
|
|
294
|
+
strokeLinejoin="round"
|
|
295
|
+
>
|
|
296
|
+
<rect x="3" y="4" width="18" height="16" rx="2" />
|
|
297
|
+
<line x1="9" y1="4" x2="9" y2="20" />
|
|
298
|
+
</svg>
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
const PanelRightIcon = () => (
|
|
302
|
+
<svg
|
|
303
|
+
width="12"
|
|
304
|
+
height="12"
|
|
305
|
+
viewBox="0 0 24 24"
|
|
306
|
+
fill="none"
|
|
307
|
+
stroke="currentColor"
|
|
308
|
+
strokeWidth="2"
|
|
309
|
+
strokeLinecap="round"
|
|
310
|
+
strokeLinejoin="round"
|
|
311
|
+
>
|
|
312
|
+
<rect x="3" y="4" width="18" height="16" rx="2" />
|
|
313
|
+
<line x1="15" y1="4" x2="15" y2="20" />
|
|
314
|
+
</svg>
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
const QuestionIcon = () => (
|
|
318
|
+
<svg
|
|
319
|
+
width="12"
|
|
320
|
+
height="12"
|
|
321
|
+
viewBox="0 0 24 24"
|
|
322
|
+
fill="none"
|
|
323
|
+
stroke="currentColor"
|
|
324
|
+
strokeWidth="2"
|
|
325
|
+
strokeLinecap="round"
|
|
326
|
+
strokeLinejoin="round"
|
|
327
|
+
>
|
|
328
|
+
<circle cx="12" cy="12" r="10" />
|
|
329
|
+
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
|
|
330
|
+
<line x1="12" y1="17" x2="12.01" y2="17" />
|
|
331
|
+
</svg>
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
const CodeIcon = () => (
|
|
335
|
+
<svg
|
|
336
|
+
width="12"
|
|
337
|
+
height="12"
|
|
338
|
+
viewBox="0 0 24 24"
|
|
339
|
+
fill="none"
|
|
340
|
+
stroke="currentColor"
|
|
341
|
+
strokeWidth="2"
|
|
342
|
+
strokeLinecap="round"
|
|
343
|
+
strokeLinejoin="round"
|
|
344
|
+
>
|
|
345
|
+
<polyline points="16 18 22 12 16 6" />
|
|
346
|
+
<polyline points="8 6 2 12 8 18" />
|
|
347
|
+
</svg>
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
const ChevronDownIcon = () => (
|
|
351
|
+
<svg
|
|
352
|
+
width="12"
|
|
353
|
+
height="12"
|
|
354
|
+
viewBox="0 0 24 24"
|
|
355
|
+
fill="none"
|
|
356
|
+
stroke="currentColor"
|
|
357
|
+
strokeWidth="2"
|
|
358
|
+
strokeLinecap="round"
|
|
359
|
+
strokeLinejoin="round"
|
|
360
|
+
>
|
|
361
|
+
<polyline points="6 9 12 15 18 9" />
|
|
362
|
+
</svg>
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
const RefreshIcon = () => (
|
|
366
|
+
<svg
|
|
367
|
+
width="12"
|
|
368
|
+
height="12"
|
|
369
|
+
viewBox="0 0 24 24"
|
|
370
|
+
fill="none"
|
|
371
|
+
stroke="currentColor"
|
|
372
|
+
strokeWidth="2"
|
|
373
|
+
strokeLinecap="round"
|
|
374
|
+
strokeLinejoin="round"
|
|
375
|
+
>
|
|
376
|
+
<polyline points="23 4 23 10 17 10" />
|
|
377
|
+
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
|
|
378
|
+
</svg>
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
const MapPinIcon = () => (
|
|
382
|
+
<svg
|
|
383
|
+
width="12"
|
|
384
|
+
height="12"
|
|
385
|
+
viewBox="0 0 24 24"
|
|
386
|
+
fill="var(--stl-color-accent)"
|
|
387
|
+
stroke="var(--stl-color-accent)"
|
|
388
|
+
strokeWidth="2"
|
|
389
|
+
strokeLinecap="round"
|
|
390
|
+
strokeLinejoin="round"
|
|
391
|
+
>
|
|
392
|
+
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
|
|
393
|
+
<circle cx="12" cy="10" r="3" fill="white" stroke="white" />
|
|
394
|
+
</svg>
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
function UserAgentIcon({ parsed }: { parsed: ParsedUserAgent }) {
|
|
398
|
+
if (parsed.type === "curl") {
|
|
399
|
+
return <TerminalIcon />;
|
|
400
|
+
}
|
|
401
|
+
if (parsed.type === "browser") {
|
|
402
|
+
return <GlobeIcon />;
|
|
403
|
+
}
|
|
404
|
+
if (parsed.type === "sdk" && parsed.language) {
|
|
405
|
+
switch (parsed.language) {
|
|
406
|
+
case "TypeScript":
|
|
407
|
+
return <TypescriptIcon className="xray-page__lang-icon" />;
|
|
408
|
+
case "Python":
|
|
409
|
+
return <PythonIcon className="xray-page__lang-icon" />;
|
|
410
|
+
case "Go":
|
|
411
|
+
return <GoIcon className="xray-page__lang-icon" />;
|
|
412
|
+
case "Java":
|
|
413
|
+
return <JavaIcon className="xray-page__lang-icon" />;
|
|
414
|
+
case "Kotlin":
|
|
415
|
+
return <KotlinIcon className="xray-page__lang-icon" />;
|
|
416
|
+
case "Ruby":
|
|
417
|
+
return <RubyIcon className="xray-page__lang-icon" />;
|
|
418
|
+
case "C#":
|
|
419
|
+
return <CSharpIcon className="xray-page__lang-icon" />;
|
|
420
|
+
default:
|
|
421
|
+
return <CodeIcon />;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return <QuestionIcon />;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export type XRayPageProps = {
|
|
428
|
+
basePath?: string;
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
export default function XRayPage({ basePath = "/xray" }: XRayPageProps) {
|
|
432
|
+
const [requests, setRequests] = useState<XrayRequestLogDetail[]>([]);
|
|
433
|
+
const [pendingRequests, setPendingRequests] = useState<
|
|
434
|
+
XrayRequestLogDetail[]
|
|
435
|
+
>([]);
|
|
436
|
+
const [searchQuery, setSearchQuery] = useState("");
|
|
437
|
+
const [searchError, setSearchError] = useState<string | null>(null);
|
|
438
|
+
const [isSearching, setIsSearching] = useState(false);
|
|
439
|
+
const [selectedRequest, setSelectedRequest] =
|
|
440
|
+
useState<XrayRequestLogDetail | null>(null);
|
|
441
|
+
const [isListCollapsed, setIsListCollapsed] = useState(false);
|
|
442
|
+
const [detailTab, setDetailTab] = useState<DetailTab>("response");
|
|
443
|
+
const [isRequestHeadersOpen, setIsRequestHeadersOpen] = useState(true);
|
|
444
|
+
const [isResponseHeadersOpen, setIsResponseHeadersOpen] = useState(true);
|
|
445
|
+
const [showCopied, setShowCopied] = useState(false);
|
|
446
|
+
const [payloadCopied, setPayloadCopied] = useState<DetailTab | null>(null);
|
|
447
|
+
const [hasLoadedInitial, setHasLoadedInitial] = useState(false);
|
|
448
|
+
const [authState, setAuthState] = useState<
|
|
449
|
+
"loading" | "authenticated" | "unauthorized"
|
|
450
|
+
>("loading");
|
|
451
|
+
const [currentUserIp, setCurrentUserIp] = useState<string | null>(null);
|
|
452
|
+
const seenRequestIds = useRef<Set<string>>(new Set());
|
|
453
|
+
const basePathRef = useRef<string>(basePath.replace(/\/$/, "") || "/");
|
|
454
|
+
|
|
455
|
+
const handleLoadPendingRequests = () => {
|
|
456
|
+
if (pendingRequests.length === 0) return;
|
|
457
|
+
// Mark pending requests as seen when user loads them
|
|
458
|
+
const newSeenIds = new Set(seenRequestIds.current);
|
|
459
|
+
pendingRequests.forEach((req) => newSeenIds.add(req.request_id));
|
|
460
|
+
saveSeenIdsToStorage(newSeenIds);
|
|
461
|
+
|
|
462
|
+
setRequests((prev) => [...pendingRequests, ...prev]);
|
|
463
|
+
setPendingRequests([]);
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
const handleCopyRequestId = (requestId: string) => {
|
|
467
|
+
navigator.clipboard.writeText(requestId);
|
|
468
|
+
setShowCopied(true);
|
|
469
|
+
setTimeout(() => setShowCopied(false), 1500);
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
const handleCopyPayload = (payload: string, tab: DetailTab) => {
|
|
473
|
+
if (!payload) return;
|
|
474
|
+
navigator.clipboard.writeText(payload);
|
|
475
|
+
setPayloadCopied(tab);
|
|
476
|
+
setTimeout(() => setPayloadCopied(null), 1500);
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
const getRequestIdFromUrl = () => {
|
|
480
|
+
const params = new URLSearchParams(window.location.search);
|
|
481
|
+
return params.get("requestId");
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
const updateUrl = (requestId: string | null, replace = false) => {
|
|
485
|
+
if (typeof window === "undefined") return;
|
|
486
|
+
const base = basePathRef.current || "/xray";
|
|
487
|
+
const url = new URL(window.location.href);
|
|
488
|
+
url.pathname = base;
|
|
489
|
+
if (requestId) {
|
|
490
|
+
url.searchParams.set("requestId", requestId);
|
|
491
|
+
} else {
|
|
492
|
+
url.searchParams.delete("requestId");
|
|
493
|
+
}
|
|
494
|
+
const nextUrl = url.pathname + url.search;
|
|
495
|
+
if (window.location.pathname + window.location.search === nextUrl) return;
|
|
496
|
+
const method = replace ? "replaceState" : "pushState";
|
|
497
|
+
window.history[method]({}, "", nextUrl);
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
const loadRequestById = async (requestId: string, replace = false) => {
|
|
501
|
+
const xrayApi = window.__XRAY_API__;
|
|
502
|
+
if (!xrayApi) return;
|
|
503
|
+
|
|
504
|
+
setIsSearching(true);
|
|
505
|
+
setSearchError(null);
|
|
506
|
+
const detail = await fetchRequestLogDetail(xrayApi, requestId);
|
|
507
|
+
|
|
508
|
+
if (detail) {
|
|
509
|
+
setSelectedRequest(detail);
|
|
510
|
+
updateUrl(detail.request_id, replace);
|
|
511
|
+
} else {
|
|
512
|
+
setSearchError("Request not found");
|
|
513
|
+
setTimeout(() => setSearchError(null), 3000);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
setIsSearching(false);
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
// Auto-search if requestId is in URL query params
|
|
520
|
+
useEffect(() => {
|
|
521
|
+
const xrayApi = window.__XRAY_API__;
|
|
522
|
+
if (!xrayApi) return;
|
|
523
|
+
|
|
524
|
+
const requestId = getRequestIdFromUrl();
|
|
525
|
+
if (!requestId) return;
|
|
526
|
+
|
|
527
|
+
loadRequestById(requestId, true);
|
|
528
|
+
}, []);
|
|
529
|
+
|
|
530
|
+
useEffect(() => {
|
|
531
|
+
if (selectedRequest) {
|
|
532
|
+
setDetailTab("response");
|
|
533
|
+
setPayloadCopied(null);
|
|
534
|
+
}
|
|
535
|
+
}, [selectedRequest?.request_id]);
|
|
536
|
+
|
|
537
|
+
useEffect(() => {
|
|
538
|
+
if (!selectedRequest) {
|
|
539
|
+
setIsListCollapsed(false);
|
|
540
|
+
}
|
|
541
|
+
}, [selectedRequest]);
|
|
542
|
+
|
|
543
|
+
// Sync pending count to storage so badge can show it
|
|
544
|
+
useEffect(() => {
|
|
545
|
+
savePendingCountToStorage(pendingRequests.length);
|
|
546
|
+
}, [pendingRequests.length]);
|
|
547
|
+
|
|
548
|
+
useEffect(() => {
|
|
549
|
+
const handlePopState = () => {
|
|
550
|
+
const requestId = getRequestIdFromUrl();
|
|
551
|
+
if (!requestId) {
|
|
552
|
+
setSelectedRequest(null);
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
if (selectedRequest?.request_id === requestId) return;
|
|
556
|
+
loadRequestById(requestId, true);
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
window.addEventListener("popstate", handlePopState);
|
|
560
|
+
return () => window.removeEventListener("popstate", handlePopState);
|
|
561
|
+
}, [selectedRequest?.request_id]);
|
|
562
|
+
|
|
563
|
+
// Poll for requests
|
|
564
|
+
useEffect(() => {
|
|
565
|
+
const xrayApi = window.__XRAY_API__;
|
|
566
|
+
if (!xrayApi) {
|
|
567
|
+
setHasLoadedInitial(true);
|
|
568
|
+
setAuthState("authenticated"); // No API configured, don't show login banner
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
let intervalId: ReturnType<typeof setInterval> | null = null;
|
|
573
|
+
let isUnauthorized = false;
|
|
574
|
+
|
|
575
|
+
const poll = async (isInitial = false) => {
|
|
576
|
+
// Skip polling if tab is not visible (unless initial load)
|
|
577
|
+
if (!isInitial && document.visibilityState !== "visible") {
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const result = await fetchRecentRequests(xrayApi);
|
|
582
|
+
|
|
583
|
+
if (result.status === "unauthorized") {
|
|
584
|
+
setAuthState("unauthorized");
|
|
585
|
+
isUnauthorized = true;
|
|
586
|
+
if (isInitial) {
|
|
587
|
+
setHasLoadedInitial(true);
|
|
588
|
+
}
|
|
589
|
+
// Stop polling on 401
|
|
590
|
+
if (intervalId) {
|
|
591
|
+
clearInterval(intervalId);
|
|
592
|
+
intervalId = null;
|
|
593
|
+
}
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const allRequests = result.data;
|
|
598
|
+
const newRequests = allRequests.filter(
|
|
599
|
+
(req) => !seenRequestIds.current.has(req.request_id),
|
|
600
|
+
);
|
|
601
|
+
|
|
602
|
+
if (newRequests.length > 0) {
|
|
603
|
+
newRequests.forEach((req) =>
|
|
604
|
+
seenRequestIds.current.add(req.request_id),
|
|
605
|
+
);
|
|
606
|
+
if (isInitial) {
|
|
607
|
+
// On initial load, show all requests directly and mark as seen
|
|
608
|
+
setRequests((prev) => [...newRequests, ...prev]);
|
|
609
|
+
// Mark all initial requests as seen in storage (badge will show 0)
|
|
610
|
+
saveSeenIdsToStorage(seenRequestIds.current);
|
|
611
|
+
} else {
|
|
612
|
+
// After initial load, add to pending (NOT marked as seen yet)
|
|
613
|
+
// Badge will show these as new when user navigates away
|
|
614
|
+
setPendingRequests((prev) => [...newRequests, ...prev]);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
if (isInitial) {
|
|
618
|
+
setHasLoadedInitial(true);
|
|
619
|
+
}
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
// Poll immediately when tab becomes visible
|
|
623
|
+
const handleVisibilityChange = () => {
|
|
624
|
+
if (document.visibilityState === "visible" && !isUnauthorized) {
|
|
625
|
+
poll();
|
|
626
|
+
}
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
const startPolling = () => {
|
|
630
|
+
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
631
|
+
poll(true);
|
|
632
|
+
intervalId = setInterval(poll, POLL_INTERVAL);
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
// First check session, then start polling if authenticated
|
|
636
|
+
fetchSessionUser(xrayApi).then((sessionResult) => {
|
|
637
|
+
if (sessionResult.status === "unauthorized") {
|
|
638
|
+
setAuthState("unauthorized");
|
|
639
|
+
setHasLoadedInitial(true);
|
|
640
|
+
isUnauthorized = true;
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
if (sessionResult.data?.user?.ip) {
|
|
645
|
+
setCurrentUserIp(sessionResult.data.user.ip);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
setAuthState("authenticated");
|
|
649
|
+
startPolling();
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
return () => {
|
|
653
|
+
if (intervalId) clearInterval(intervalId);
|
|
654
|
+
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
655
|
+
};
|
|
656
|
+
}, []);
|
|
657
|
+
|
|
658
|
+
const handleSearch = async () => {
|
|
659
|
+
const requestId = searchQuery.trim();
|
|
660
|
+
if (!requestId) return;
|
|
661
|
+
|
|
662
|
+
const xrayApi = window.__XRAY_API__;
|
|
663
|
+
if (!xrayApi) return;
|
|
664
|
+
|
|
665
|
+
setIsSearching(true);
|
|
666
|
+
setSearchError(null);
|
|
667
|
+
|
|
668
|
+
const detail = await fetchRequestLogDetail(xrayApi, requestId);
|
|
669
|
+
|
|
670
|
+
if (detail) {
|
|
671
|
+
setSelectedRequest(detail);
|
|
672
|
+
setSearchQuery("");
|
|
673
|
+
updateUrl(detail.request_id);
|
|
674
|
+
} else {
|
|
675
|
+
setSearchError("Request not found");
|
|
676
|
+
setTimeout(() => setSearchError(null), 3000);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
setIsSearching(false);
|
|
680
|
+
};
|
|
681
|
+
|
|
682
|
+
const hasSelection = selectedRequest !== null;
|
|
683
|
+
const isListCollapsedActive = hasSelection && isListCollapsed;
|
|
684
|
+
|
|
685
|
+
const getRequestHref = (requestId: string) => {
|
|
686
|
+
const base = basePathRef.current || "/xray";
|
|
687
|
+
return `${base}?requestId=${encodeURIComponent(requestId)}`;
|
|
688
|
+
};
|
|
689
|
+
|
|
690
|
+
const baseHref = basePathRef.current || "/xray";
|
|
691
|
+
|
|
692
|
+
const isModifiedClick = (event: MouseEvent<HTMLAnchorElement>) =>
|
|
693
|
+
event.metaKey ||
|
|
694
|
+
event.ctrlKey ||
|
|
695
|
+
event.shiftKey ||
|
|
696
|
+
event.altKey ||
|
|
697
|
+
event.button !== 0;
|
|
698
|
+
|
|
699
|
+
const handleRequestLinkClick = (
|
|
700
|
+
event: MouseEvent<HTMLAnchorElement>,
|
|
701
|
+
req: XrayRequestLogDetail,
|
|
702
|
+
) => {
|
|
703
|
+
if (isModifiedClick(event)) return;
|
|
704
|
+
event.preventDefault();
|
|
705
|
+
setSelectedRequest(req);
|
|
706
|
+
updateUrl(req.request_id);
|
|
707
|
+
};
|
|
708
|
+
|
|
709
|
+
const handleCloseLinkClick = (event: MouseEvent<HTMLAnchorElement>) => {
|
|
710
|
+
if (isModifiedClick(event)) return;
|
|
711
|
+
event.preventDefault();
|
|
712
|
+
setSelectedRequest(null);
|
|
713
|
+
setIsListCollapsed(false);
|
|
714
|
+
updateUrl(null);
|
|
715
|
+
};
|
|
716
|
+
|
|
717
|
+
const detailTabs: { id: DetailTab; label: string }[] = [
|
|
718
|
+
{ id: "request", label: "Request" },
|
|
719
|
+
{ id: "response", label: "Response" },
|
|
720
|
+
];
|
|
721
|
+
|
|
722
|
+
const renderHeadersBlock = (
|
|
723
|
+
headers: Record<string, string[]> | null,
|
|
724
|
+
emptyMessage: string,
|
|
725
|
+
isOpen: boolean,
|
|
726
|
+
onToggle: () => void,
|
|
727
|
+
) => (
|
|
728
|
+
<div className="xray-page__detail-block xray-page__detail-block--headers">
|
|
729
|
+
<button
|
|
730
|
+
type="button"
|
|
731
|
+
className="xray-page__detail-block-toggle"
|
|
732
|
+
aria-expanded={isOpen}
|
|
733
|
+
onClick={onToggle}
|
|
734
|
+
>
|
|
735
|
+
<span className="xray-page__detail-block-label">Headers</span>
|
|
736
|
+
<span className="xray-page__detail-block-count">
|
|
737
|
+
{headers ? Object.keys(headers).length : 0}
|
|
738
|
+
</span>
|
|
739
|
+
<span
|
|
740
|
+
className={`xray-page__detail-block-chevron ${isOpen ? "is-open" : ""}`}
|
|
741
|
+
>
|
|
742
|
+
<ChevronDownIcon />
|
|
743
|
+
</span>
|
|
744
|
+
</button>
|
|
745
|
+
{isOpen &&
|
|
746
|
+
(headers && Object.keys(headers).length > 0 ? (
|
|
747
|
+
<HeadersList headers={headers} />
|
|
748
|
+
) : (
|
|
749
|
+
<div className="xray-page__detail-empty">{emptyMessage}</div>
|
|
750
|
+
))}
|
|
751
|
+
</div>
|
|
752
|
+
);
|
|
753
|
+
|
|
754
|
+
const renderBodyBlock = (
|
|
755
|
+
body: string,
|
|
756
|
+
truncated: boolean | null,
|
|
757
|
+
emptyMessage: string,
|
|
758
|
+
tab: DetailTab,
|
|
759
|
+
) => {
|
|
760
|
+
const hasBody = body && body.trim().length > 0;
|
|
761
|
+
return (
|
|
762
|
+
<div className="xray-page__detail-block xray-page__detail-block--payload">
|
|
763
|
+
<div className="xray-page__detail-block-header">
|
|
764
|
+
<span className="xray-page__detail-block-label">Body</span>
|
|
765
|
+
{truncated && (
|
|
766
|
+
<span className="xray-page__detail-pill">Truncated</span>
|
|
767
|
+
)}
|
|
768
|
+
</div>
|
|
769
|
+
{hasBody ? (
|
|
770
|
+
<div className="xray-page__payload">
|
|
771
|
+
<div className="xray-page__payload-actions">
|
|
772
|
+
<button
|
|
773
|
+
className="xray-page__detail-copy"
|
|
774
|
+
type="button"
|
|
775
|
+
onClick={() => handleCopyPayload(body, tab)}
|
|
776
|
+
aria-label="Copy body"
|
|
777
|
+
>
|
|
778
|
+
<CopyIcon />
|
|
779
|
+
</button>
|
|
780
|
+
{payloadCopied === tab && (
|
|
781
|
+
<span className="xray-page__detail-copy-toast">Copied</span>
|
|
782
|
+
)}
|
|
783
|
+
</div>
|
|
784
|
+
<HighlightedJson body={body} />
|
|
785
|
+
</div>
|
|
786
|
+
) : (
|
|
787
|
+
<div className="xray-page__payload xray-page__payload--empty">
|
|
788
|
+
<div className="xray-page__detail-empty">{emptyMessage}</div>
|
|
789
|
+
</div>
|
|
790
|
+
)}
|
|
791
|
+
</div>
|
|
792
|
+
);
|
|
793
|
+
};
|
|
794
|
+
|
|
795
|
+
const renderDetailContent = () => {
|
|
796
|
+
if (!selectedRequest) return null;
|
|
797
|
+
|
|
798
|
+
switch (detailTab) {
|
|
799
|
+
case "request":
|
|
800
|
+
return (
|
|
801
|
+
<div className="xray-page__detail-stack">
|
|
802
|
+
{renderHeadersBlock(
|
|
803
|
+
selectedRequest.request_headers,
|
|
804
|
+
"No request headers captured.",
|
|
805
|
+
isRequestHeadersOpen,
|
|
806
|
+
() => setIsRequestHeadersOpen((prev) => !prev),
|
|
807
|
+
)}
|
|
808
|
+
{renderBodyBlock(
|
|
809
|
+
selectedRequest.request_body,
|
|
810
|
+
selectedRequest.request_body_truncated,
|
|
811
|
+
"No request body captured.",
|
|
812
|
+
"request",
|
|
813
|
+
)}
|
|
814
|
+
</div>
|
|
815
|
+
);
|
|
816
|
+
default:
|
|
817
|
+
return (
|
|
818
|
+
<div className="xray-page__detail-stack">
|
|
819
|
+
{renderHeadersBlock(
|
|
820
|
+
selectedRequest.response_headers,
|
|
821
|
+
"No response headers captured.",
|
|
822
|
+
isResponseHeadersOpen,
|
|
823
|
+
() => setIsResponseHeadersOpen((prev) => !prev),
|
|
824
|
+
)}
|
|
825
|
+
{renderBodyBlock(
|
|
826
|
+
selectedRequest.response_body,
|
|
827
|
+
selectedRequest.response_body_truncated,
|
|
828
|
+
"No response body captured.",
|
|
829
|
+
"response",
|
|
830
|
+
)}
|
|
831
|
+
</div>
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
};
|
|
835
|
+
|
|
836
|
+
// Show simple login banner when unauthorized
|
|
837
|
+
if (authState === "unauthorized") {
|
|
838
|
+
return (
|
|
839
|
+
<div className="xray-page">
|
|
840
|
+
<div className="xray-page__login-banner">
|
|
841
|
+
<p>Sign in to view X-ray</p>
|
|
842
|
+
<p className="xray-page__login-hint">
|
|
843
|
+
You need to be signed in to view API request logs.
|
|
844
|
+
</p>
|
|
845
|
+
</div>
|
|
846
|
+
</div>
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// Show loading state
|
|
851
|
+
if (!hasLoadedInitial) {
|
|
852
|
+
return (
|
|
853
|
+
<div className="xray-page">
|
|
854
|
+
<div className="xray-page__loading">Loading...</div>
|
|
855
|
+
</div>
|
|
856
|
+
);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
return (
|
|
860
|
+
<div
|
|
861
|
+
className={`xray-page ${hasSelection ? "xray-page--with-detail" : ""} ${
|
|
862
|
+
isListCollapsedActive ? "xray-page--list-collapsed" : ""
|
|
863
|
+
}`}
|
|
864
|
+
>
|
|
865
|
+
<div className="xray-page__header">
|
|
866
|
+
<h1 className="xray-page__title">X-ray</h1>
|
|
867
|
+
<div className="xray-page__search">
|
|
868
|
+
<div className="xray-page__search-container">
|
|
869
|
+
<input
|
|
870
|
+
type="text"
|
|
871
|
+
className={`xray-page__search-input ${searchError ? "xray-page__search-input--error" : ""}`}
|
|
872
|
+
placeholder="Search by Request ID..."
|
|
873
|
+
value={searchQuery}
|
|
874
|
+
onChange={(e) => {
|
|
875
|
+
setSearchQuery(e.target.value);
|
|
876
|
+
setSearchError(null);
|
|
877
|
+
}}
|
|
878
|
+
onKeyDown={(e) => {
|
|
879
|
+
if (e.key === "Enter" && searchQuery.trim() && !isSearching) {
|
|
880
|
+
e.preventDefault();
|
|
881
|
+
handleSearch();
|
|
882
|
+
}
|
|
883
|
+
}}
|
|
884
|
+
disabled={isSearching}
|
|
885
|
+
/>
|
|
886
|
+
<button
|
|
887
|
+
className="xray-page__search-btn"
|
|
888
|
+
onClick={handleSearch}
|
|
889
|
+
disabled={isSearching || !searchQuery.trim()}
|
|
890
|
+
aria-label="Search"
|
|
891
|
+
>
|
|
892
|
+
<svg
|
|
893
|
+
width="16"
|
|
894
|
+
height="16"
|
|
895
|
+
viewBox="0 0 24 24"
|
|
896
|
+
fill="none"
|
|
897
|
+
stroke="currentColor"
|
|
898
|
+
strokeWidth="2"
|
|
899
|
+
>
|
|
900
|
+
<circle cx="11" cy="11" r="8" />
|
|
901
|
+
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
|
902
|
+
</svg>
|
|
903
|
+
</button>
|
|
904
|
+
{searchError && (
|
|
905
|
+
<div className="xray-page__search-error">{searchError}</div>
|
|
906
|
+
)}
|
|
907
|
+
</div>
|
|
908
|
+
</div>
|
|
909
|
+
</div>
|
|
910
|
+
|
|
911
|
+
<div className="xray-page__content">
|
|
912
|
+
<div
|
|
913
|
+
className="xray-page__list-panel"
|
|
914
|
+
aria-hidden={isListCollapsedActive}
|
|
915
|
+
>
|
|
916
|
+
<div className="xray-page__list-container">
|
|
917
|
+
<div className="xray-page__list-header">
|
|
918
|
+
<span className="xray-page__list-title">Requests</span>
|
|
919
|
+
<div className="xray-page__list-header-actions">
|
|
920
|
+
{pendingRequests.length > 0 && (
|
|
921
|
+
<button
|
|
922
|
+
className="xray-page__new-requests-btn"
|
|
923
|
+
type="button"
|
|
924
|
+
onClick={handleLoadPendingRequests}
|
|
925
|
+
aria-label={`Load ${pendingRequests.length} new requests`}
|
|
926
|
+
>
|
|
927
|
+
<RefreshIcon />
|
|
928
|
+
<span>{pendingRequests.length} new</span>
|
|
929
|
+
</button>
|
|
930
|
+
)}
|
|
931
|
+
<button
|
|
932
|
+
className={`xray-page__list-toggle ${hasSelection ? "" : "is-hidden"}`}
|
|
933
|
+
type="button"
|
|
934
|
+
onClick={() => setIsListCollapsed((prev) => !prev)}
|
|
935
|
+
aria-label={isListCollapsedActive ? "Show list" : "Hide list"}
|
|
936
|
+
aria-hidden={!hasSelection}
|
|
937
|
+
disabled={!hasSelection}
|
|
938
|
+
tabIndex={hasSelection ? 0 : -1}
|
|
939
|
+
>
|
|
940
|
+
{isListCollapsedActive ? (
|
|
941
|
+
<PanelRightIcon />
|
|
942
|
+
) : (
|
|
943
|
+
<PanelLeftIcon />
|
|
944
|
+
)}
|
|
945
|
+
</button>
|
|
946
|
+
</div>
|
|
947
|
+
</div>
|
|
948
|
+
{requests.length === 0 ? (
|
|
949
|
+
<div className="xray-page__empty">
|
|
950
|
+
<p>No requests yet.</p>
|
|
951
|
+
<p className="xray-page__empty-hint">
|
|
952
|
+
Make some API requests to your application and they'll appear
|
|
953
|
+
here.
|
|
954
|
+
</p>
|
|
955
|
+
</div>
|
|
956
|
+
) : (
|
|
957
|
+
<ul className="xray-page__list">
|
|
958
|
+
{requests.map((req) => (
|
|
959
|
+
<li key={req.request_id} className="xray-page__item">
|
|
960
|
+
<a
|
|
961
|
+
className={`xray-page__item-link ${
|
|
962
|
+
selectedRequest?.request_id === req.request_id
|
|
963
|
+
? "xray-page__item-link--selected"
|
|
964
|
+
: ""
|
|
965
|
+
}`}
|
|
966
|
+
href={getRequestHref(req.request_id)}
|
|
967
|
+
onClick={(event) => handleRequestLinkClick(event, req)}
|
|
968
|
+
>
|
|
969
|
+
<span
|
|
970
|
+
className={`xray-page__status-badge ${getStatusClass(req.response_status_code)}`}
|
|
971
|
+
>
|
|
972
|
+
<span className="xray-page__status-dot" />
|
|
973
|
+
{req.response_status_code}
|
|
974
|
+
</span>
|
|
975
|
+
<span className="xray-page__endpoint">
|
|
976
|
+
<span className="xray-page__method">
|
|
977
|
+
{req.request_method}
|
|
978
|
+
</span>
|
|
979
|
+
<span className="xray-page__path">
|
|
980
|
+
{req.request_path || "/"}
|
|
981
|
+
</span>
|
|
982
|
+
</span>
|
|
983
|
+
<span className="xray-page__time">
|
|
984
|
+
{formatRelativeTime(req.timestamp)}
|
|
985
|
+
</span>
|
|
986
|
+
</a>
|
|
987
|
+
</li>
|
|
988
|
+
))}
|
|
989
|
+
</ul>
|
|
990
|
+
)}
|
|
991
|
+
</div>
|
|
992
|
+
</div>
|
|
993
|
+
|
|
994
|
+
<div
|
|
995
|
+
className="xray-page__detail-panel"
|
|
996
|
+
data-state={hasSelection ? "open" : "closed"}
|
|
997
|
+
>
|
|
998
|
+
{!hasSelection ? (
|
|
999
|
+
<div className="xray-page__detail-placeholder">
|
|
1000
|
+
<p>Select a request to inspect its details.</p>
|
|
1001
|
+
</div>
|
|
1002
|
+
) : (
|
|
1003
|
+
<>
|
|
1004
|
+
<div className="xray-page__detail-header">
|
|
1005
|
+
<div className="xray-page__detail-title">
|
|
1006
|
+
{isListCollapsedActive && (
|
|
1007
|
+
<button
|
|
1008
|
+
className="xray-page__detail-toggle"
|
|
1009
|
+
type="button"
|
|
1010
|
+
onClick={() => setIsListCollapsed(false)}
|
|
1011
|
+
aria-label="Show list"
|
|
1012
|
+
>
|
|
1013
|
+
<PanelRightIcon />
|
|
1014
|
+
</button>
|
|
1015
|
+
)}
|
|
1016
|
+
<span className="xray-page__method">
|
|
1017
|
+
{selectedRequest.request_method}
|
|
1018
|
+
</span>
|
|
1019
|
+
<span className="xray-page__detail-path">
|
|
1020
|
+
{selectedRequest.request_path || "/"}
|
|
1021
|
+
</span>
|
|
1022
|
+
</div>
|
|
1023
|
+
<a
|
|
1024
|
+
className="xray-page__detail-close"
|
|
1025
|
+
href={baseHref}
|
|
1026
|
+
onClick={handleCloseLinkClick}
|
|
1027
|
+
aria-label="Close"
|
|
1028
|
+
>
|
|
1029
|
+
<svg
|
|
1030
|
+
width="20"
|
|
1031
|
+
height="20"
|
|
1032
|
+
viewBox="0 0 24 24"
|
|
1033
|
+
fill="none"
|
|
1034
|
+
stroke="currentColor"
|
|
1035
|
+
strokeWidth="2"
|
|
1036
|
+
>
|
|
1037
|
+
<line x1="18" y1="6" x2="6" y2="18" />
|
|
1038
|
+
<line x1="6" y1="6" x2="18" y2="18" />
|
|
1039
|
+
</svg>
|
|
1040
|
+
</a>
|
|
1041
|
+
</div>
|
|
1042
|
+
|
|
1043
|
+
{(() => {
|
|
1044
|
+
const requestHeaders = selectedRequest.request_headers;
|
|
1045
|
+
const uaValue = requestHeaders
|
|
1046
|
+
? Object.entries(requestHeaders).find(
|
|
1047
|
+
([k]) => k.toLowerCase() === "user-agent",
|
|
1048
|
+
)?.[1]
|
|
1049
|
+
: undefined;
|
|
1050
|
+
const userAgent = Array.isArray(uaValue) ? uaValue[0] : uaValue;
|
|
1051
|
+
const parsed = parseUserAgent(userAgent);
|
|
1052
|
+
|
|
1053
|
+
const responseHeaders = selectedRequest.response_headers;
|
|
1054
|
+
const locationValue = responseHeaders
|
|
1055
|
+
? Object.entries(responseHeaders).find(
|
|
1056
|
+
([k]) => k.toLowerCase() === "location",
|
|
1057
|
+
)?.[1]
|
|
1058
|
+
: undefined;
|
|
1059
|
+
const location = Array.isArray(locationValue)
|
|
1060
|
+
? locationValue[0]
|
|
1061
|
+
: locationValue;
|
|
1062
|
+
|
|
1063
|
+
const endpointMap = window.__XRAY_ENDPOINT_MAP__ as
|
|
1064
|
+
| EndpointMap
|
|
1065
|
+
| undefined;
|
|
1066
|
+
const serverBasePaths = window.__XRAY_SERVER_BASE_PATHS__ as
|
|
1067
|
+
| string[]
|
|
1068
|
+
| undefined;
|
|
1069
|
+
const endpointInfo = getEndpointInfo(
|
|
1070
|
+
selectedRequest.request_method,
|
|
1071
|
+
selectedRequest.request_route,
|
|
1072
|
+
endpointMap,
|
|
1073
|
+
serverBasePaths,
|
|
1074
|
+
);
|
|
1075
|
+
|
|
1076
|
+
return (
|
|
1077
|
+
<div className="xray-page__detail-info">
|
|
1078
|
+
{endpointInfo && (
|
|
1079
|
+
<div className="xray-page__detail-row">
|
|
1080
|
+
<span className="xray-page__detail-row-icon" />
|
|
1081
|
+
<span className="xray-page__detail-row-label">
|
|
1082
|
+
Endpoint:
|
|
1083
|
+
</span>
|
|
1084
|
+
<a
|
|
1085
|
+
href={endpointInfo.href}
|
|
1086
|
+
className="xray-page__detail-row-value xray-page__endpoint-link"
|
|
1087
|
+
>
|
|
1088
|
+
{endpointInfo.title}
|
|
1089
|
+
</a>
|
|
1090
|
+
</div>
|
|
1091
|
+
)}
|
|
1092
|
+
<div className="xray-page__detail-row">
|
|
1093
|
+
<span
|
|
1094
|
+
className={`xray-page__detail-row-icon xray-page__status-dot ${getStatusClass(selectedRequest.response_status_code)}`}
|
|
1095
|
+
/>
|
|
1096
|
+
<span className="xray-page__detail-row-label">
|
|
1097
|
+
Status:
|
|
1098
|
+
</span>
|
|
1099
|
+
<span
|
|
1100
|
+
className={`xray-page__detail-row-value ${getStatusClass(selectedRequest.response_status_code)}`}
|
|
1101
|
+
>
|
|
1102
|
+
{selectedRequest.response_status_code}{" "}
|
|
1103
|
+
{getStatusText(selectedRequest.response_status_code)}
|
|
1104
|
+
</span>
|
|
1105
|
+
</div>
|
|
1106
|
+
<div className="xray-page__detail-row">
|
|
1107
|
+
<span className="xray-page__detail-row-icon" />
|
|
1108
|
+
<span className="xray-page__detail-row-label">
|
|
1109
|
+
Duration:
|
|
1110
|
+
</span>
|
|
1111
|
+
<span className="xray-page__detail-row-value">
|
|
1112
|
+
{Math.ceil(selectedRequest.duration_us / 1000)}ms
|
|
1113
|
+
</span>
|
|
1114
|
+
</div>
|
|
1115
|
+
<div className="xray-page__detail-row">
|
|
1116
|
+
<span className="xray-page__detail-row-icon" />
|
|
1117
|
+
<span className="xray-page__detail-row-label">Time:</span>
|
|
1118
|
+
<span className="xray-page__detail-row-value">
|
|
1119
|
+
{formatDate(selectedRequest.timestamp)}{" "}
|
|
1120
|
+
{formatTime(selectedRequest.timestamp)}
|
|
1121
|
+
</span>
|
|
1122
|
+
</div>
|
|
1123
|
+
{selectedRequest.ip && (
|
|
1124
|
+
<div className="xray-page__detail-row">
|
|
1125
|
+
<span className="xray-page__detail-row-icon">
|
|
1126
|
+
{currentUserIp &&
|
|
1127
|
+
selectedRequest.ip === currentUserIp && (
|
|
1128
|
+
<button
|
|
1129
|
+
type="button"
|
|
1130
|
+
className="xray-page__current-ip-pin"
|
|
1131
|
+
aria-label="Your current IP address"
|
|
1132
|
+
>
|
|
1133
|
+
<MapPinIcon />
|
|
1134
|
+
</button>
|
|
1135
|
+
)}
|
|
1136
|
+
</span>
|
|
1137
|
+
<span className="xray-page__detail-row-label">
|
|
1138
|
+
IP Address:
|
|
1139
|
+
</span>
|
|
1140
|
+
<span className="xray-page__detail-row-value">
|
|
1141
|
+
{selectedRequest.ip}
|
|
1142
|
+
{currentUserIp &&
|
|
1143
|
+
selectedRequest.ip === currentUserIp && (
|
|
1144
|
+
<span className="xray-page__current-ip-badge">
|
|
1145
|
+
Your current IP address
|
|
1146
|
+
</span>
|
|
1147
|
+
)}
|
|
1148
|
+
</span>
|
|
1149
|
+
</div>
|
|
1150
|
+
)}
|
|
1151
|
+
<div className="xray-page__detail-row">
|
|
1152
|
+
<span className="xray-page__detail-row-icon" />
|
|
1153
|
+
<span className="xray-page__detail-row-label">
|
|
1154
|
+
Request ID:
|
|
1155
|
+
</span>
|
|
1156
|
+
<code className="xray-page__detail-row-value xray-page__request-id">
|
|
1157
|
+
{selectedRequest.request_id}
|
|
1158
|
+
</code>
|
|
1159
|
+
<span className="xray-page__copy-wrapper">
|
|
1160
|
+
<button
|
|
1161
|
+
className="xray-page__copy-btn"
|
|
1162
|
+
onClick={() =>
|
|
1163
|
+
handleCopyRequestId(selectedRequest.request_id)
|
|
1164
|
+
}
|
|
1165
|
+
aria-label="Copy request ID"
|
|
1166
|
+
>
|
|
1167
|
+
<CopyIcon />
|
|
1168
|
+
</button>
|
|
1169
|
+
{showCopied && (
|
|
1170
|
+
<span className="xray-page__copy-toast">Copied</span>
|
|
1171
|
+
)}
|
|
1172
|
+
</span>
|
|
1173
|
+
</div>
|
|
1174
|
+
<div className="xray-page__detail-row-group">
|
|
1175
|
+
<div className="xray-page__detail-row">
|
|
1176
|
+
<span className="xray-page__detail-row-icon">
|
|
1177
|
+
<UserAgentIcon parsed={parsed} />
|
|
1178
|
+
</span>
|
|
1179
|
+
<span className="xray-page__detail-row-label">
|
|
1180
|
+
User Agent:
|
|
1181
|
+
</span>
|
|
1182
|
+
<span className="xray-page__detail-row-value">
|
|
1183
|
+
{parsed.type === "sdk"
|
|
1184
|
+
? userAgent
|
|
1185
|
+
: formatUserAgentSummary(parsed)}
|
|
1186
|
+
</span>
|
|
1187
|
+
</div>
|
|
1188
|
+
{parsed.type !== "sdk" && userAgent && (
|
|
1189
|
+
<div className="xray-page__detail-row-sub">
|
|
1190
|
+
{userAgent}
|
|
1191
|
+
</div>
|
|
1192
|
+
)}
|
|
1193
|
+
</div>
|
|
1194
|
+
{location && (
|
|
1195
|
+
<div className="xray-page__detail-row">
|
|
1196
|
+
<span className="xray-page__detail-row-icon" />
|
|
1197
|
+
<span className="xray-page__detail-row-label">
|
|
1198
|
+
Location:
|
|
1199
|
+
</span>
|
|
1200
|
+
<a
|
|
1201
|
+
href={location}
|
|
1202
|
+
target="_blank"
|
|
1203
|
+
rel="noopener noreferrer"
|
|
1204
|
+
className="xray-page__detail-row-value xray-page__detail-location-link"
|
|
1205
|
+
>
|
|
1206
|
+
{location}
|
|
1207
|
+
</a>
|
|
1208
|
+
</div>
|
|
1209
|
+
)}
|
|
1210
|
+
</div>
|
|
1211
|
+
);
|
|
1212
|
+
})()}
|
|
1213
|
+
|
|
1214
|
+
<div className="xray-page__detail-body">
|
|
1215
|
+
<div
|
|
1216
|
+
className="xray-page__detail-tabs"
|
|
1217
|
+
role="tablist"
|
|
1218
|
+
aria-label="Request details"
|
|
1219
|
+
>
|
|
1220
|
+
{detailTabs.map((tab) => (
|
|
1221
|
+
<button
|
|
1222
|
+
key={tab.id}
|
|
1223
|
+
type="button"
|
|
1224
|
+
role="tab"
|
|
1225
|
+
aria-selected={detailTab === tab.id}
|
|
1226
|
+
className={`xray-page__detail-tab ${detailTab === tab.id ? "xray-page__detail-tab--active" : ""}`}
|
|
1227
|
+
onClick={() => setDetailTab(tab.id)}
|
|
1228
|
+
>
|
|
1229
|
+
{tab.label}
|
|
1230
|
+
</button>
|
|
1231
|
+
))}
|
|
1232
|
+
</div>
|
|
1233
|
+
<div className="xray-page__detail-panel-body" role="tabpanel">
|
|
1234
|
+
{renderDetailContent()}
|
|
1235
|
+
</div>
|
|
1236
|
+
</div>
|
|
1237
|
+
</>
|
|
1238
|
+
)}
|
|
1239
|
+
</div>
|
|
1240
|
+
</div>
|
|
1241
|
+
</div>
|
|
1242
|
+
);
|
|
1243
|
+
}
|