@geekmidas/studio 0.2.0 → 0.4.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.
Files changed (75) hide show
  1. package/dist/{DataBrowser-hGwiTffZ.d.cts → DataBrowser-B-jz8KBR.d.mts} +5 -2
  2. package/dist/DataBrowser-B-jz8KBR.d.mts.map +1 -0
  3. package/dist/{DataBrowser-SOcqmZb2.d.mts → DataBrowser-BTe9HWJy.d.cts} +5 -2
  4. package/dist/DataBrowser-BTe9HWJy.d.cts.map +1 -0
  5. package/dist/{DataBrowser-c-Gs6PZB.cjs → DataBrowser-D8c_pBf4.cjs} +4 -4
  6. package/dist/DataBrowser-D8c_pBf4.cjs.map +1 -0
  7. package/dist/{DataBrowser-DQ3-ZxdV.mjs → DataBrowser-kgcI9ApJ.mjs} +4 -4
  8. package/dist/DataBrowser-kgcI9ApJ.mjs.map +1 -0
  9. package/dist/Studio-CYzz3wD2.d.cts +152 -0
  10. package/dist/Studio-CYzz3wD2.d.cts.map +1 -0
  11. package/dist/Studio-D5yGscb8.d.mts +152 -0
  12. package/dist/Studio-D5yGscb8.d.mts.map +1 -0
  13. package/dist/data/index.cjs +1 -1
  14. package/dist/data/index.d.cts +1 -1
  15. package/dist/data/index.d.mts +1 -1
  16. package/dist/data/index.mjs +1 -1
  17. package/dist/index.cjs +33 -3
  18. package/dist/index.cjs.map +1 -1
  19. package/dist/index.d.cts +4 -131
  20. package/dist/index.d.mts +4 -131
  21. package/dist/index.mjs +33 -3
  22. package/dist/index.mjs.map +1 -1
  23. package/dist/server/hono.cjs +168 -21
  24. package/dist/server/hono.cjs.map +1 -1
  25. package/dist/server/hono.d.cts +13 -2
  26. package/dist/server/hono.d.cts.map +1 -0
  27. package/dist/server/hono.d.mts +13 -2
  28. package/dist/server/hono.d.mts.map +1 -0
  29. package/dist/server/hono.mjs +168 -21
  30. package/dist/server/hono.mjs.map +1 -1
  31. package/dist/types-BZv87Ikv.mjs.map +1 -1
  32. package/dist/types-CMttUZYk.cjs.map +1 -1
  33. package/package.json +5 -5
  34. package/src/Studio.ts +341 -292
  35. package/src/__tests__/Studio.spec.ts +447 -0
  36. package/src/data/DataBrowser.ts +147 -143
  37. package/src/data/__tests__/DataBrowser.integration.spec.ts +404 -404
  38. package/src/data/__tests__/filtering.integration.spec.ts +726 -726
  39. package/src/data/__tests__/introspection.integration.spec.ts +340 -340
  40. package/src/data/__tests__/pagination.spec.ts +123 -0
  41. package/src/data/filtering.ts +154 -154
  42. package/src/data/introspection.ts +141 -141
  43. package/src/data/pagination.ts +15 -15
  44. package/src/index.ts +22 -24
  45. package/src/server/__tests__/hono.integration.spec.ts +605 -347
  46. package/src/server/hono.ts +392 -190
  47. package/src/types.ts +138 -138
  48. package/src/ui-assets.ts +10 -13
  49. package/tsconfig.json +9 -0
  50. package/tsdown.config.ts +9 -9
  51. package/ui/package.json +28 -22
  52. package/ui/src/App.tsx +95 -235
  53. package/ui/src/api.ts +184 -42
  54. package/ui/src/components/FilterPanel.tsx +198 -198
  55. package/ui/src/components/NavRail.tsx +183 -0
  56. package/ui/src/components/RowDetail.tsx +106 -106
  57. package/ui/src/components/StudioHeader.tsx +109 -0
  58. package/ui/src/components/TableList.tsx +49 -49
  59. package/ui/src/components/TableView.tsx +530 -485
  60. package/ui/src/main.tsx +3 -3
  61. package/ui/src/pages/DashboardPage.tsx +500 -0
  62. package/ui/src/pages/DatabasePage.tsx +226 -0
  63. package/ui/src/pages/EndpointDetailsPage.tsx +288 -0
  64. package/ui/src/pages/ExceptionsPage.tsx +268 -0
  65. package/ui/src/pages/LogsPage.tsx +228 -0
  66. package/ui/src/pages/MonitoringPage.tsx +46 -0
  67. package/ui/src/pages/PerformancePage.tsx +307 -0
  68. package/ui/src/pages/RequestsPage.tsx +379 -0
  69. package/ui/src/providers/StudioProvider.tsx +194 -0
  70. package/ui/src/styles.css +53 -142
  71. package/ui/src/types.ts +154 -30
  72. package/ui/tsconfig.tsbuildinfo +1 -1
  73. package/ui/vite.config.ts +6 -6
  74. package/dist/DataBrowser-DQ3-ZxdV.mjs.map +0 -1
  75. package/dist/DataBrowser-c-Gs6PZB.cjs.map +0 -1
@@ -0,0 +1,307 @@
1
+ import {
2
+ AreaTimeSeriesChart,
3
+ Badge,
4
+ BarListChart,
5
+ createTimeRange,
6
+ LatencyPercentilesChart,
7
+ MetricCard,
8
+ StatusDistributionChart,
9
+ type TimeRange,
10
+ TimeRangeSelector,
11
+ } from '@geekmidas/ui';
12
+ import { useCallback, useEffect, useState } from 'react';
13
+ import { Link } from 'react-router-dom';
14
+ import { getEndpointMetrics, getMetrics, getStatusDistribution } from '../api';
15
+ import type {
16
+ EndpointMetrics,
17
+ RequestMetrics,
18
+ StatusDistribution,
19
+ } from '../types';
20
+
21
+ function formatDuration(ms: number): string {
22
+ if (ms < 1) return '<1ms';
23
+ if (ms < 1000) return `${Math.round(ms)}ms`;
24
+ return `${(ms / 1000).toFixed(2)}s`;
25
+ }
26
+
27
+ function formatNumber(n: number): string {
28
+ if (n < 1000) return String(Math.round(n));
29
+ if (n < 1000000) return `${(n / 1000).toFixed(1)}k`;
30
+ return `${(n / 1000000).toFixed(1)}M`;
31
+ }
32
+
33
+ function formatPercent(p: number): string {
34
+ return `${p.toFixed(1)}%`;
35
+ }
36
+
37
+ function formatTimestamp(ts: number): string {
38
+ return new Date(ts).toLocaleTimeString();
39
+ }
40
+
41
+ function getMethodColor(
42
+ method: string,
43
+ ): 'default' | 'success' | 'warning' | 'destructive' {
44
+ switch (method.toUpperCase()) {
45
+ case 'GET':
46
+ return 'success';
47
+ case 'POST':
48
+ return 'default';
49
+ case 'PUT':
50
+ case 'PATCH':
51
+ return 'warning';
52
+ case 'DELETE':
53
+ return 'destructive';
54
+ default:
55
+ return 'default';
56
+ }
57
+ }
58
+
59
+ export function PerformancePage() {
60
+ const [metrics, setMetrics] = useState<RequestMetrics | null>(null);
61
+ const [endpoints, setEndpoints] = useState<EndpointMetrics[]>([]);
62
+ const [statusDist, setStatusDist] = useState<StatusDistribution | null>(null);
63
+ const [loading, setLoading] = useState(true);
64
+ const [error, setError] = useState<string | null>(null);
65
+ const [timeRange, setTimeRange] = useState<TimeRange>(() =>
66
+ createTimeRange('1h'),
67
+ );
68
+
69
+ const fetchData = useCallback(async () => {
70
+ try {
71
+ setLoading(true);
72
+ const timeRangeParams = {
73
+ start: timeRange.start.toISOString(),
74
+ end: timeRange.end.toISOString(),
75
+ };
76
+ const [metricsData, endpointsData, statusData] = await Promise.all([
77
+ getMetrics(timeRangeParams),
78
+ getEndpointMetrics({ limit: 10, ...timeRangeParams }),
79
+ getStatusDistribution(timeRangeParams),
80
+ ]);
81
+ setMetrics(metricsData);
82
+ setEndpoints(endpointsData);
83
+ setStatusDist(statusData);
84
+ setError(null);
85
+ } catch (err) {
86
+ setError(err instanceof Error ? err.message : 'Failed to load metrics');
87
+ } finally {
88
+ setLoading(false);
89
+ }
90
+ }, [timeRange.start, timeRange.end]);
91
+
92
+ useEffect(() => {
93
+ fetchData();
94
+
95
+ // Refresh every 30 seconds
96
+ const interval = setInterval(fetchData, 30000);
97
+ return () => clearInterval(interval);
98
+ }, [fetchData]);
99
+
100
+ if (loading && !metrics) {
101
+ return (
102
+ <div className="flex-1 flex items-center justify-center text-muted-foreground">
103
+ Loading performance data...
104
+ </div>
105
+ );
106
+ }
107
+
108
+ if (error) {
109
+ return (
110
+ <div className="flex-1 flex items-center justify-center text-red-500">
111
+ {error}
112
+ </div>
113
+ );
114
+ }
115
+
116
+ return (
117
+ <div className="p-6 space-y-6">
118
+ <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
119
+ <div>
120
+ <h1 className="text-xl font-semibold">Performance</h1>
121
+ <p className="text-sm text-muted-foreground mt-1">
122
+ Endpoint metrics and latency insights
123
+ </p>
124
+ </div>
125
+ <TimeRangeSelector value={timeRange} onChange={setTimeRange} />
126
+ </div>
127
+
128
+ {/* Overview Metrics */}
129
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
130
+ <MetricCard
131
+ title="Total Requests"
132
+ value={formatNumber(metrics?.totalRequests ?? 0)}
133
+ trend="neutral"
134
+ trendValue={`${(metrics?.requestsPerSecond ?? 0).toFixed(1)}/s`}
135
+ />
136
+ <MetricCard
137
+ title="Avg Duration"
138
+ value={formatDuration(metrics?.avgDuration ?? 0)}
139
+ trend="neutral"
140
+ trendValue={`p95: ${formatDuration(metrics?.p95Duration ?? 0)}`}
141
+ />
142
+ <MetricCard
143
+ title="Success Rate"
144
+ value={formatPercent(metrics?.successRate ?? 100)}
145
+ trend={
146
+ (metrics?.successRate ?? 100) >= 99
147
+ ? 'up'
148
+ : (metrics?.successRate ?? 100) >= 95
149
+ ? 'neutral'
150
+ : 'down'
151
+ }
152
+ />
153
+ <MetricCard
154
+ title="Error Rate"
155
+ value={formatPercent(metrics?.errorRate ?? 0)}
156
+ trend={
157
+ (metrics?.errorRate ?? 0) <= 1
158
+ ? 'up'
159
+ : (metrics?.errorRate ?? 0) <= 5
160
+ ? 'neutral'
161
+ : 'down'
162
+ }
163
+ />
164
+ </div>
165
+
166
+ {/* Charts Row */}
167
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
168
+ {/* Status Distribution */}
169
+ <div className="bg-card rounded-lg border p-4">
170
+ <h2 className="text-sm font-medium mb-4">Status Distribution</h2>
171
+ {statusDist ? (
172
+ <StatusDistributionChart data={statusDist} />
173
+ ) : (
174
+ <div className="h-48 flex items-center justify-center text-sm text-muted-foreground">
175
+ No requests recorded yet
176
+ </div>
177
+ )}
178
+ </div>
179
+
180
+ {/* Latency Percentiles */}
181
+ <div className="bg-card rounded-lg border p-4">
182
+ <h2 className="text-sm font-medium mb-4">Latency Percentiles</h2>
183
+ {metrics ? (
184
+ <LatencyPercentilesChart
185
+ p50={metrics.p50Duration}
186
+ p95={metrics.p95Duration}
187
+ p99={metrics.p99Duration}
188
+ />
189
+ ) : (
190
+ <div className="h-48 flex items-center justify-center text-sm text-muted-foreground">
191
+ No requests recorded yet
192
+ </div>
193
+ )}
194
+ </div>
195
+ </div>
196
+
197
+ {/* Request Volume Time Series */}
198
+ <div className="bg-card rounded-lg border p-4">
199
+ <h2 className="text-sm font-medium mb-4">Request Volume</h2>
200
+ <AreaTimeSeriesChart
201
+ data={(metrics?.timeSeries ?? []).map((p) => ({
202
+ timestamp: p.timestamp,
203
+ value: p.count,
204
+ secondaryValue: p.errorCount,
205
+ }))}
206
+ primaryLabel="Requests"
207
+ secondaryLabel="Errors"
208
+ primaryColor="blue"
209
+ secondaryColor="red"
210
+ />
211
+ </div>
212
+
213
+ {/* Top Endpoints - Chart View */}
214
+ <div className="bg-card rounded-lg border p-4">
215
+ <h2 className="text-sm font-medium mb-4">
216
+ Top Endpoints by Request Count
217
+ </h2>
218
+ <BarListChart
219
+ data={endpoints.map((ep) => ({
220
+ name: `${ep.method} ${ep.path}`,
221
+ value: ep.count,
222
+ }))}
223
+ emptyMessage="No endpoints recorded yet"
224
+ />
225
+ </div>
226
+
227
+ {/* Top Endpoints - Table View */}
228
+ <div className="bg-card rounded-lg border">
229
+ <div className="p-4 border-b">
230
+ <h2 className="text-sm font-medium">Endpoint Details</h2>
231
+ </div>
232
+ <div className="overflow-x-auto">
233
+ <table className="w-full text-sm">
234
+ <thead className="bg-muted/50">
235
+ <tr>
236
+ <th className="text-left px-4 py-2 font-medium">Endpoint</th>
237
+ <th className="text-right px-4 py-2 font-medium">Requests</th>
238
+ <th className="text-right px-4 py-2 font-medium">Avg</th>
239
+ <th className="text-right px-4 py-2 font-medium">p95</th>
240
+ <th className="text-right px-4 py-2 font-medium">Error Rate</th>
241
+ <th className="text-right px-4 py-2 font-medium">Last Seen</th>
242
+ </tr>
243
+ </thead>
244
+ <tbody>
245
+ {endpoints.length === 0 ? (
246
+ <tr>
247
+ <td
248
+ colSpan={6}
249
+ className="text-center py-8 text-muted-foreground"
250
+ >
251
+ No endpoints recorded yet
252
+ </td>
253
+ </tr>
254
+ ) : (
255
+ endpoints.map((ep, i) => (
256
+ <tr
257
+ key={i}
258
+ className="border-t hover:bg-muted/30 cursor-pointer"
259
+ >
260
+ <td className="px-4 py-2">
261
+ <Link
262
+ to={`/performance/endpoint?method=${encodeURIComponent(ep.method)}&path=${encodeURIComponent(ep.path)}`}
263
+ className="flex items-center gap-2"
264
+ >
265
+ <Badge variant={getMethodColor(ep.method)}>
266
+ {ep.method}
267
+ </Badge>
268
+ <span className="font-mono text-xs hover:underline">
269
+ {ep.path}
270
+ </span>
271
+ </Link>
272
+ </td>
273
+ <td className="text-right px-4 py-2 font-mono">
274
+ {formatNumber(ep.count)}
275
+ </td>
276
+ <td className="text-right px-4 py-2 font-mono">
277
+ {formatDuration(ep.avgDuration)}
278
+ </td>
279
+ <td className="text-right px-4 py-2 font-mono">
280
+ {formatDuration(ep.p95Duration)}
281
+ </td>
282
+ <td className="text-right px-4 py-2">
283
+ <span
284
+ className={
285
+ ep.errorRate > 5
286
+ ? 'text-red-500'
287
+ : ep.errorRate > 1
288
+ ? 'text-yellow-500'
289
+ : 'text-green-500'
290
+ }
291
+ >
292
+ {formatPercent(ep.errorRate)}
293
+ </span>
294
+ </td>
295
+ <td className="text-right px-4 py-2 text-muted-foreground">
296
+ {formatTimestamp(ep.lastSeen)}
297
+ </td>
298
+ </tr>
299
+ ))
300
+ )}
301
+ </tbody>
302
+ </table>
303
+ </div>
304
+ </div>
305
+ </div>
306
+ );
307
+ }
@@ -0,0 +1,379 @@
1
+ import { HttpMethodBadge, HttpStatusBadge, NoResults } from '@geekmidas/ui';
2
+ import { ArrowLeft, X } from 'lucide-react';
3
+ import { useCallback, useEffect, useMemo, useState } from 'react';
4
+ import { useNavigate, useParams } from 'react-router-dom';
5
+ import * as api from '../api';
6
+ import { useStudio } from '../providers/StudioProvider';
7
+ import type { RequestEntry } from '../types';
8
+
9
+ interface RequestFilters {
10
+ search: string;
11
+ method: string;
12
+ status: string;
13
+ }
14
+
15
+ export function RequestsPage() {
16
+ const { id } = useParams<{ id: string }>();
17
+ const navigate = useNavigate();
18
+ const { requests: realtimeRequests } = useStudio();
19
+
20
+ const [requests, setRequests] = useState<RequestEntry[]>([]);
21
+ const [selectedRequest, setSelectedRequest] = useState<RequestEntry | null>(
22
+ null,
23
+ );
24
+ const [loading, setLoading] = useState(true);
25
+ const [filters, setFilters] = useState<RequestFilters>({
26
+ search: '',
27
+ method: '',
28
+ status: '',
29
+ });
30
+
31
+ // Merge realtime requests with fetched requests
32
+ useEffect(() => {
33
+ setRequests((prev) => {
34
+ const existingIds = new Set(prev.map((r) => r.id));
35
+ const newRequests = realtimeRequests.filter(
36
+ (r) => !existingIds.has(r.id),
37
+ );
38
+ if (newRequests.length > 0) {
39
+ return [...newRequests, ...prev].slice(0, 100);
40
+ }
41
+ return prev;
42
+ });
43
+ }, [realtimeRequests]);
44
+
45
+ // Load requests
46
+ const loadRequests = useCallback(async () => {
47
+ try {
48
+ setLoading(true);
49
+ const data = await api.getRequests({
50
+ limit: 100,
51
+ search: filters.search || undefined,
52
+ method: filters.method || undefined,
53
+ status: filters.status || undefined,
54
+ });
55
+ setRequests(data);
56
+ } catch (_error) {
57
+ } finally {
58
+ setLoading(false);
59
+ }
60
+ }, [filters]);
61
+
62
+ useEffect(() => {
63
+ loadRequests();
64
+ }, [loadRequests]);
65
+
66
+ // Load selected request detail
67
+ useEffect(() => {
68
+ if (id) {
69
+ const existing = requests.find((r) => r.id === id);
70
+ if (existing) {
71
+ setSelectedRequest(existing);
72
+ } else {
73
+ api.getRequest(id).then(setSelectedRequest).catch(console.error);
74
+ }
75
+ } else {
76
+ setSelectedRequest(null);
77
+ }
78
+ }, [id, requests]);
79
+
80
+ // Filter requests
81
+ const filteredRequests = useMemo(() => {
82
+ return requests.filter((request) => {
83
+ if (
84
+ filters.search &&
85
+ !request.path.toLowerCase().includes(filters.search.toLowerCase())
86
+ ) {
87
+ return false;
88
+ }
89
+ if (filters.method && request.method !== filters.method) {
90
+ return false;
91
+ }
92
+ if (filters.status) {
93
+ const statusCategory = Math.floor(request.status / 100);
94
+ const filterCategory = parseInt(filters.status[0], 10);
95
+ if (statusCategory !== filterCategory) {
96
+ return false;
97
+ }
98
+ }
99
+ return true;
100
+ });
101
+ }, [requests, filters]);
102
+
103
+ const hasFilters = filters.search || filters.method || filters.status;
104
+
105
+ const clearFilters = () => {
106
+ setFilters({ search: '', method: '', status: '' });
107
+ };
108
+
109
+ return (
110
+ <div className="flex h-full">
111
+ {/* Request List */}
112
+ <div className="flex-1 flex flex-col overflow-hidden">
113
+ {/* Filter Bar */}
114
+ <div className="p-4 border-b border-border bg-surface flex items-center gap-4">
115
+ <input
116
+ type="text"
117
+ placeholder="Search paths..."
118
+ value={filters.search}
119
+ onChange={(e) =>
120
+ setFilters((f) => ({ ...f, search: e.target.value }))
121
+ }
122
+ className="flex-1 bg-background border border-border rounded px-3 py-1.5 text-sm focus:outline-none focus:border-accent"
123
+ />
124
+ <select
125
+ value={filters.method}
126
+ onChange={(e) =>
127
+ setFilters((f) => ({ ...f, method: e.target.value }))
128
+ }
129
+ className="bg-background border border-border rounded px-3 py-1.5 text-sm focus:outline-none focus:border-accent"
130
+ >
131
+ <option value="">All Methods</option>
132
+ <option value="GET">GET</option>
133
+ <option value="POST">POST</option>
134
+ <option value="PUT">PUT</option>
135
+ <option value="PATCH">PATCH</option>
136
+ <option value="DELETE">DELETE</option>
137
+ </select>
138
+ <select
139
+ value={filters.status}
140
+ onChange={(e) =>
141
+ setFilters((f) => ({ ...f, status: e.target.value }))
142
+ }
143
+ className="bg-background border border-border rounded px-3 py-1.5 text-sm focus:outline-none focus:border-accent"
144
+ >
145
+ <option value="">All Status</option>
146
+ <option value="2xx">2xx Success</option>
147
+ <option value="3xx">3xx Redirect</option>
148
+ <option value="4xx">4xx Client Error</option>
149
+ <option value="5xx">5xx Server Error</option>
150
+ </select>
151
+ {hasFilters && (
152
+ <button
153
+ onClick={clearFilters}
154
+ className="text-sm text-muted-foreground hover:text-foreground"
155
+ >
156
+ Clear
157
+ </button>
158
+ )}
159
+ </div>
160
+
161
+ {/* Request List */}
162
+ <div className="flex-1 overflow-auto p-4">
163
+ {loading ? (
164
+ <div className="flex items-center justify-center py-16 text-muted-foreground">
165
+ Loading...
166
+ </div>
167
+ ) : filteredRequests.length === 0 ? (
168
+ <NoResults
169
+ title={hasFilters ? 'No matching requests' : 'No requests yet'}
170
+ description={
171
+ hasFilters
172
+ ? 'Try adjusting your filters.'
173
+ : 'Requests will appear here as they are captured.'
174
+ }
175
+ />
176
+ ) : (
177
+ <div className="flex flex-col gap-2">
178
+ {filteredRequests.map((request) => (
179
+ <div
180
+ key={request.id}
181
+ className={`bg-surface border rounded-lg p-4 cursor-pointer transition-colors hover:border-accent/50 flex items-center gap-4 ${
182
+ selectedRequest?.id === request.id
183
+ ? 'border-accent'
184
+ : 'border-border'
185
+ }`}
186
+ onClick={() => navigate(`/monitoring/requests/${request.id}`)}
187
+ >
188
+ <HttpMethodBadge method={request.method as any} size="sm" />
189
+ <span className="flex-1 truncate font-mono text-sm">
190
+ {request.path}
191
+ </span>
192
+ <HttpStatusBadge code={request.status} size="sm" />
193
+ <span className="text-xs text-muted-foreground min-w-16 text-right">
194
+ {formatDuration(request.duration)}
195
+ </span>
196
+ <span className="text-xs text-muted-foreground min-w-20 text-right">
197
+ {formatTime(request.timestamp)}
198
+ </span>
199
+ </div>
200
+ ))}
201
+ </div>
202
+ )}
203
+ </div>
204
+ </div>
205
+
206
+ {/* Request Detail Panel */}
207
+ {selectedRequest && (
208
+ <RequestDetailPanel
209
+ request={selectedRequest}
210
+ onClose={() => navigate('/monitoring/requests')}
211
+ />
212
+ )}
213
+ </div>
214
+ );
215
+ }
216
+
217
+ function RequestDetailPanel({
218
+ request,
219
+ onClose,
220
+ }: {
221
+ request: RequestEntry;
222
+ onClose: () => void;
223
+ }) {
224
+ const [activeTab, setActiveTab] = useState<
225
+ 'request' | 'response' | 'headers'
226
+ >('request');
227
+
228
+ return (
229
+ <div className="w-[500px] border-l border-border bg-surface flex flex-col">
230
+ {/* Header */}
231
+ <div className="flex items-center justify-between p-4 border-b border-border">
232
+ <div className="flex items-center gap-3">
233
+ <button
234
+ onClick={onClose}
235
+ className="p-1 hover:bg-surface-hover rounded"
236
+ >
237
+ <ArrowLeft className="h-4 w-4" />
238
+ </button>
239
+ <div>
240
+ <div className="flex items-center gap-2">
241
+ <HttpMethodBadge method={request.method as any} size="sm" />
242
+ <HttpStatusBadge code={request.status} size="sm" />
243
+ </div>
244
+ <p className="text-sm text-muted-foreground font-mono mt-1 truncate max-w-[350px]">
245
+ {request.path}
246
+ </p>
247
+ </div>
248
+ </div>
249
+ <button
250
+ onClick={onClose}
251
+ className="p-1 hover:bg-surface-hover rounded"
252
+ >
253
+ <X className="h-4 w-4" />
254
+ </button>
255
+ </div>
256
+
257
+ {/* Tabs */}
258
+ <div className="flex border-b border-border">
259
+ {(['request', 'response', 'headers'] as const).map((tab) => (
260
+ <button
261
+ key={tab}
262
+ className={`px-4 py-2 text-sm capitalize border-b-2 transition-colors ${
263
+ activeTab === tab
264
+ ? 'text-accent border-accent'
265
+ : 'text-muted-foreground border-transparent hover:text-foreground'
266
+ }`}
267
+ onClick={() => setActiveTab(tab)}
268
+ >
269
+ {tab}
270
+ </button>
271
+ ))}
272
+ </div>
273
+
274
+ {/* Content */}
275
+ <div className="flex-1 overflow-auto p-4">
276
+ {activeTab === 'request' && (
277
+ <div className="space-y-4">
278
+ <InfoRow
279
+ label="Duration"
280
+ value={formatDuration(request.duration)}
281
+ />
282
+ <InfoRow label="Time" value={formatTime(request.timestamp)} />
283
+ {request.ip && <InfoRow label="IP" value={request.ip} />}
284
+ {request.userAgent && (
285
+ <InfoRow label="User Agent" value={request.userAgent} />
286
+ )}
287
+ {request.requestBody !== undefined &&
288
+ request.requestBody !== null && (
289
+ <div>
290
+ <h4 className="text-sm font-medium mb-2">Request Body</h4>
291
+ <pre className="bg-background rounded p-3 text-xs overflow-auto max-h-[300px]">
292
+ {formatBody(request.requestBody)}
293
+ </pre>
294
+ </div>
295
+ )}
296
+ </div>
297
+ )}
298
+
299
+ {activeTab === 'response' && (
300
+ <div className="space-y-4">
301
+ <InfoRow label="Status" value={`${request.status}`} />
302
+ {request.responseBody !== undefined &&
303
+ request.responseBody !== null && (
304
+ <div>
305
+ <h4 className="text-sm font-medium mb-2">Response Body</h4>
306
+ <pre className="bg-background rounded p-3 text-xs overflow-auto max-h-[400px]">
307
+ {formatBody(request.responseBody)}
308
+ </pre>
309
+ </div>
310
+ )}
311
+ </div>
312
+ )}
313
+
314
+ {activeTab === 'headers' && (
315
+ <div className="space-y-6">
316
+ {request.requestHeaders &&
317
+ Object.keys(request.requestHeaders).length > 0 && (
318
+ <div>
319
+ <h4 className="text-sm font-medium mb-2">Request Headers</h4>
320
+ <div className="bg-background rounded p-3 space-y-1">
321
+ {Object.entries(request.requestHeaders).map(
322
+ ([key, value]) => (
323
+ <div key={key} className="text-xs">
324
+ <span className="text-muted-foreground">{key}:</span>{' '}
325
+ <span className="font-mono">{String(value)}</span>
326
+ </div>
327
+ ),
328
+ )}
329
+ </div>
330
+ </div>
331
+ )}
332
+ {request.responseHeaders &&
333
+ Object.keys(request.responseHeaders).length > 0 && (
334
+ <div>
335
+ <h4 className="text-sm font-medium mb-2">Response Headers</h4>
336
+ <div className="bg-background rounded p-3 space-y-1">
337
+ {Object.entries(request.responseHeaders).map(
338
+ ([key, value]) => (
339
+ <div key={key} className="text-xs">
340
+ <span className="text-muted-foreground">{key}:</span>{' '}
341
+ <span className="font-mono">{String(value)}</span>
342
+ </div>
343
+ ),
344
+ )}
345
+ </div>
346
+ </div>
347
+ )}
348
+ </div>
349
+ )}
350
+ </div>
351
+ </div>
352
+ );
353
+ }
354
+
355
+ function InfoRow({ label, value }: { label: string; value: string }) {
356
+ return (
357
+ <div className="flex items-center gap-2">
358
+ <span className="text-sm text-muted-foreground w-24">{label}</span>
359
+ <span className="text-sm font-mono">{value}</span>
360
+ </div>
361
+ );
362
+ }
363
+
364
+ function formatDuration(ms: number) {
365
+ if (ms < 1) return '<1ms';
366
+ if (ms < 1000) return `${Math.round(ms)}ms`;
367
+ return `${(ms / 1000).toFixed(2)}s`;
368
+ }
369
+
370
+ function formatTime(timestamp: string) {
371
+ return new Date(timestamp).toLocaleTimeString();
372
+ }
373
+
374
+ function formatBody(body: unknown): string {
375
+ if (typeof body === 'string') {
376
+ return body;
377
+ }
378
+ return JSON.stringify(body, null, 2);
379
+ }