@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.
- package/dist/{DataBrowser-hGwiTffZ.d.cts → DataBrowser-B-jz8KBR.d.mts} +5 -2
- package/dist/DataBrowser-B-jz8KBR.d.mts.map +1 -0
- package/dist/{DataBrowser-SOcqmZb2.d.mts → DataBrowser-BTe9HWJy.d.cts} +5 -2
- package/dist/DataBrowser-BTe9HWJy.d.cts.map +1 -0
- package/dist/{DataBrowser-c-Gs6PZB.cjs → DataBrowser-D8c_pBf4.cjs} +4 -4
- package/dist/DataBrowser-D8c_pBf4.cjs.map +1 -0
- package/dist/{DataBrowser-DQ3-ZxdV.mjs → DataBrowser-kgcI9ApJ.mjs} +4 -4
- package/dist/DataBrowser-kgcI9ApJ.mjs.map +1 -0
- package/dist/Studio-CYzz3wD2.d.cts +152 -0
- package/dist/Studio-CYzz3wD2.d.cts.map +1 -0
- package/dist/Studio-D5yGscb8.d.mts +152 -0
- package/dist/Studio-D5yGscb8.d.mts.map +1 -0
- package/dist/data/index.cjs +1 -1
- package/dist/data/index.d.cts +1 -1
- package/dist/data/index.d.mts +1 -1
- package/dist/data/index.mjs +1 -1
- package/dist/index.cjs +33 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +4 -131
- package/dist/index.d.mts +4 -131
- package/dist/index.mjs +33 -3
- package/dist/index.mjs.map +1 -1
- package/dist/server/hono.cjs +168 -21
- package/dist/server/hono.cjs.map +1 -1
- package/dist/server/hono.d.cts +13 -2
- package/dist/server/hono.d.cts.map +1 -0
- package/dist/server/hono.d.mts +13 -2
- package/dist/server/hono.d.mts.map +1 -0
- package/dist/server/hono.mjs +168 -21
- package/dist/server/hono.mjs.map +1 -1
- package/dist/types-BZv87Ikv.mjs.map +1 -1
- package/dist/types-CMttUZYk.cjs.map +1 -1
- package/package.json +5 -5
- package/src/Studio.ts +341 -292
- package/src/__tests__/Studio.spec.ts +447 -0
- package/src/data/DataBrowser.ts +147 -143
- package/src/data/__tests__/DataBrowser.integration.spec.ts +404 -404
- package/src/data/__tests__/filtering.integration.spec.ts +726 -726
- package/src/data/__tests__/introspection.integration.spec.ts +340 -340
- package/src/data/__tests__/pagination.spec.ts +123 -0
- package/src/data/filtering.ts +154 -154
- package/src/data/introspection.ts +141 -141
- package/src/data/pagination.ts +15 -15
- package/src/index.ts +22 -24
- package/src/server/__tests__/hono.integration.spec.ts +605 -347
- package/src/server/hono.ts +392 -190
- package/src/types.ts +138 -138
- package/src/ui-assets.ts +10 -13
- package/tsconfig.json +9 -0
- package/tsdown.config.ts +9 -9
- package/ui/package.json +28 -22
- package/ui/src/App.tsx +95 -235
- package/ui/src/api.ts +184 -42
- package/ui/src/components/FilterPanel.tsx +198 -198
- package/ui/src/components/NavRail.tsx +183 -0
- package/ui/src/components/RowDetail.tsx +106 -106
- package/ui/src/components/StudioHeader.tsx +109 -0
- package/ui/src/components/TableList.tsx +49 -49
- package/ui/src/components/TableView.tsx +530 -485
- package/ui/src/main.tsx +3 -3
- package/ui/src/pages/DashboardPage.tsx +500 -0
- package/ui/src/pages/DatabasePage.tsx +226 -0
- package/ui/src/pages/EndpointDetailsPage.tsx +288 -0
- package/ui/src/pages/ExceptionsPage.tsx +268 -0
- package/ui/src/pages/LogsPage.tsx +228 -0
- package/ui/src/pages/MonitoringPage.tsx +46 -0
- package/ui/src/pages/PerformancePage.tsx +307 -0
- package/ui/src/pages/RequestsPage.tsx +379 -0
- package/ui/src/providers/StudioProvider.tsx +194 -0
- package/ui/src/styles.css +53 -142
- package/ui/src/types.ts +154 -30
- package/ui/tsconfig.tsbuildinfo +1 -1
- package/ui/vite.config.ts +6 -6
- package/dist/DataBrowser-DQ3-ZxdV.mjs.map +0 -1
- 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
|
+
}
|