@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.
@@ -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
+ }