@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,226 @@
|
|
|
1
|
+
import { NoData } from '@geekmidas/ui';
|
|
2
|
+
import { Database, Search } from 'lucide-react';
|
|
3
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
4
|
+
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
|
5
|
+
import * as api from '../api';
|
|
6
|
+
import { RowDetail } from '../components/RowDetail';
|
|
7
|
+
import type { ForeignKeyClickInfo } from '../components/TableView';
|
|
8
|
+
import { TableView } from '../components/TableView';
|
|
9
|
+
import type { FilterConfig, TableInfo, TableSummary } from '../types';
|
|
10
|
+
|
|
11
|
+
export function DatabasePage() {
|
|
12
|
+
const { table: tableParam } = useParams<{ table: string }>();
|
|
13
|
+
const [searchParams] = useSearchParams();
|
|
14
|
+
const navigate = useNavigate();
|
|
15
|
+
|
|
16
|
+
const [tables, setTables] = useState<TableSummary[]>([]);
|
|
17
|
+
const [selectedTable, setSelectedTable] = useState<string | null>(
|
|
18
|
+
tableParam || null,
|
|
19
|
+
);
|
|
20
|
+
const [tableInfo, setTableInfo] = useState<TableInfo | null>(null);
|
|
21
|
+
const [selectedRow, setSelectedRow] = useState<Record<
|
|
22
|
+
string,
|
|
23
|
+
unknown
|
|
24
|
+
> | null>(null);
|
|
25
|
+
const [loading, setLoading] = useState(true);
|
|
26
|
+
const [error, setError] = useState<string | null>(null);
|
|
27
|
+
const [searchTerm, setSearchTerm] = useState('');
|
|
28
|
+
const [initialFilters, setInitialFilters] = useState<FilterConfig[]>([]);
|
|
29
|
+
|
|
30
|
+
// Parse filter from URL query params
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
const filterColumn = searchParams.get('filterColumn');
|
|
33
|
+
const filterValue = searchParams.get('filterValue');
|
|
34
|
+
|
|
35
|
+
if (filterColumn && filterValue) {
|
|
36
|
+
setInitialFilters([
|
|
37
|
+
{
|
|
38
|
+
column: filterColumn,
|
|
39
|
+
operator: 'eq',
|
|
40
|
+
value: filterValue,
|
|
41
|
+
},
|
|
42
|
+
]);
|
|
43
|
+
} else {
|
|
44
|
+
setInitialFilters([]);
|
|
45
|
+
}
|
|
46
|
+
}, [searchParams]);
|
|
47
|
+
|
|
48
|
+
// Update selected table when URL param changes
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (tableParam) {
|
|
51
|
+
setSelectedTable(tableParam);
|
|
52
|
+
}
|
|
53
|
+
}, [tableParam]);
|
|
54
|
+
|
|
55
|
+
// Load tables on mount
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
async function loadTables() {
|
|
58
|
+
try {
|
|
59
|
+
const data = await api.getTables();
|
|
60
|
+
setTables(data.tables);
|
|
61
|
+
// Auto-select first table if none selected
|
|
62
|
+
if (data.tables.length > 0 && !selectedTable) {
|
|
63
|
+
const firstTable = data.tables[0].name;
|
|
64
|
+
setSelectedTable(firstTable);
|
|
65
|
+
navigate(`/database/${firstTable}`, { replace: true });
|
|
66
|
+
}
|
|
67
|
+
} catch (err) {
|
|
68
|
+
setError(err instanceof Error ? err.message : 'Failed to load tables');
|
|
69
|
+
} finally {
|
|
70
|
+
setLoading(false);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
loadTables();
|
|
74
|
+
}, [navigate, selectedTable]);
|
|
75
|
+
|
|
76
|
+
// Load table info when selected
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (!selectedTable) {
|
|
79
|
+
setTableInfo(null);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function loadTableInfo() {
|
|
84
|
+
try {
|
|
85
|
+
const info = await api.getTableInfo(selectedTable!);
|
|
86
|
+
setTableInfo(info);
|
|
87
|
+
} catch (err) {
|
|
88
|
+
setError(
|
|
89
|
+
err instanceof Error ? err.message : 'Failed to load table info',
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
loadTableInfo();
|
|
94
|
+
}, [selectedTable]);
|
|
95
|
+
|
|
96
|
+
const handleRefresh = useCallback(async () => {
|
|
97
|
+
setLoading(true);
|
|
98
|
+
try {
|
|
99
|
+
await api.getSchema(true);
|
|
100
|
+
const data = await api.getTables();
|
|
101
|
+
setTables(data.tables);
|
|
102
|
+
} catch (err) {
|
|
103
|
+
setError(err instanceof Error ? err.message : 'Failed to refresh');
|
|
104
|
+
} finally {
|
|
105
|
+
setLoading(false);
|
|
106
|
+
}
|
|
107
|
+
}, []);
|
|
108
|
+
|
|
109
|
+
const handleSelectTable = useCallback(
|
|
110
|
+
(tableName: string) => {
|
|
111
|
+
setSelectedTable(tableName);
|
|
112
|
+
setSelectedRow(null);
|
|
113
|
+
navigate(`/database/${tableName}`);
|
|
114
|
+
},
|
|
115
|
+
[navigate],
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const handleForeignKeyClick = useCallback(
|
|
119
|
+
(info: ForeignKeyClickInfo) => {
|
|
120
|
+
const valueStr = String(info.value);
|
|
121
|
+
navigate(
|
|
122
|
+
`/database/${info.targetTable}?filterColumn=${encodeURIComponent(info.targetColumn)}&filterValue=${encodeURIComponent(valueStr)}`,
|
|
123
|
+
);
|
|
124
|
+
},
|
|
125
|
+
[navigate],
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
// Filter tables by search term
|
|
129
|
+
const filteredTables = tables.filter((t) =>
|
|
130
|
+
t.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<div className="flex h-full">
|
|
135
|
+
{/* Table Sidebar */}
|
|
136
|
+
<aside className="w-64 bg-surface border-r border-border flex flex-col shrink-0">
|
|
137
|
+
<div className="p-3 border-b border-border">
|
|
138
|
+
<div className="relative">
|
|
139
|
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
140
|
+
<input
|
|
141
|
+
type="text"
|
|
142
|
+
placeholder="Search tables..."
|
|
143
|
+
value={searchTerm}
|
|
144
|
+
onChange={(e) => setSearchTerm(e.target.value)}
|
|
145
|
+
className="w-full bg-background border border-border rounded px-3 py-1.5 pl-9 text-sm placeholder-muted-foreground focus:outline-none focus:border-accent"
|
|
146
|
+
/>
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
|
|
150
|
+
<div className="flex-1 overflow-auto">
|
|
151
|
+
{loading && tables.length === 0 ? (
|
|
152
|
+
<div className="p-4 text-center text-muted-foreground text-sm">
|
|
153
|
+
Loading...
|
|
154
|
+
</div>
|
|
155
|
+
) : (
|
|
156
|
+
<div className="py-1">
|
|
157
|
+
{filteredTables.map((table) => (
|
|
158
|
+
<button
|
|
159
|
+
key={table.name}
|
|
160
|
+
onClick={() => handleSelectTable(table.name)}
|
|
161
|
+
className={`w-full flex items-center gap-2 px-3 py-2 text-sm transition-colors ${
|
|
162
|
+
selectedTable === table.name
|
|
163
|
+
? 'bg-accent/10 text-accent'
|
|
164
|
+
: 'text-foreground hover:bg-surface-hover'
|
|
165
|
+
}`}
|
|
166
|
+
>
|
|
167
|
+
<Database className="h-4 w-4 shrink-0" />
|
|
168
|
+
<span className="truncate">{table.name}</span>
|
|
169
|
+
{table.estimatedRowCount !== undefined && (
|
|
170
|
+
<span className="ml-auto text-xs text-muted-foreground">
|
|
171
|
+
{table.estimatedRowCount.toLocaleString()}
|
|
172
|
+
</span>
|
|
173
|
+
)}
|
|
174
|
+
</button>
|
|
175
|
+
))}
|
|
176
|
+
</div>
|
|
177
|
+
)}
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
<div className="p-3 border-t border-border flex items-center justify-between text-xs text-muted-foreground">
|
|
181
|
+
<span>{tables.length} tables</span>
|
|
182
|
+
<button
|
|
183
|
+
onClick={handleRefresh}
|
|
184
|
+
disabled={loading}
|
|
185
|
+
className="hover:text-foreground transition-colors"
|
|
186
|
+
>
|
|
187
|
+
Refresh
|
|
188
|
+
</button>
|
|
189
|
+
</div>
|
|
190
|
+
</aside>
|
|
191
|
+
|
|
192
|
+
{/* Main Area */}
|
|
193
|
+
<div className="flex-1 flex flex-col overflow-hidden">
|
|
194
|
+
{error ? (
|
|
195
|
+
<div className="flex-1 flex items-center justify-center text-red-400">
|
|
196
|
+
<div className="text-center">
|
|
197
|
+
<p>{error}</p>
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
) : !selectedTable ? (
|
|
201
|
+
<NoData
|
|
202
|
+
title="Select a table"
|
|
203
|
+
description="Choose a table from the sidebar to view its data."
|
|
204
|
+
/>
|
|
205
|
+
) : (
|
|
206
|
+
<TableView
|
|
207
|
+
tableName={selectedTable}
|
|
208
|
+
tableInfo={tableInfo}
|
|
209
|
+
onRowSelect={setSelectedRow}
|
|
210
|
+
onForeignKeyClick={handleForeignKeyClick}
|
|
211
|
+
initialFilters={initialFilters}
|
|
212
|
+
/>
|
|
213
|
+
)}
|
|
214
|
+
</div>
|
|
215
|
+
|
|
216
|
+
{/* Row Detail Panel */}
|
|
217
|
+
{selectedRow && tableInfo && (
|
|
218
|
+
<RowDetail
|
|
219
|
+
row={selectedRow}
|
|
220
|
+
columns={tableInfo.columns}
|
|
221
|
+
onClose={() => setSelectedRow(null)}
|
|
222
|
+
/>
|
|
223
|
+
)}
|
|
224
|
+
</div>
|
|
225
|
+
);
|
|
226
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AreaTimeSeriesChart,
|
|
3
|
+
Badge,
|
|
4
|
+
createTimeRange,
|
|
5
|
+
LatencyPercentilesChart,
|
|
6
|
+
MetricCard,
|
|
7
|
+
StatusDistributionChart,
|
|
8
|
+
type TimeRange,
|
|
9
|
+
TimeRangeSelector,
|
|
10
|
+
} from '@geekmidas/ui';
|
|
11
|
+
import { ArrowLeft } from 'lucide-react';
|
|
12
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
13
|
+
import { Link, useSearchParams } from 'react-router-dom';
|
|
14
|
+
import { getEndpointDetails, getRequests } from '../api';
|
|
15
|
+
import type { EndpointDetails, RequestEntry } from '../types';
|
|
16
|
+
|
|
17
|
+
function formatDuration(ms: number): string {
|
|
18
|
+
if (ms < 1) return '<1ms';
|
|
19
|
+
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
20
|
+
return `${(ms / 1000).toFixed(2)}s`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function formatNumber(n: number): string {
|
|
24
|
+
if (n < 1000) return String(Math.round(n));
|
|
25
|
+
if (n < 1000000) return `${(n / 1000).toFixed(1)}k`;
|
|
26
|
+
return `${(n / 1000000).toFixed(1)}M`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function formatPercent(p: number): string {
|
|
30
|
+
return `${p.toFixed(1)}%`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function formatTimestamp(ts: number | string): string {
|
|
34
|
+
return new Date(ts).toLocaleString();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function getMethodVariant(
|
|
38
|
+
method: string,
|
|
39
|
+
): 'get' | 'post' | 'put' | 'patch' | 'delete' | 'default' {
|
|
40
|
+
const m = method.toLowerCase();
|
|
41
|
+
if (
|
|
42
|
+
m === 'get' ||
|
|
43
|
+
m === 'post' ||
|
|
44
|
+
m === 'put' ||
|
|
45
|
+
m === 'patch' ||
|
|
46
|
+
m === 'delete'
|
|
47
|
+
) {
|
|
48
|
+
return m;
|
|
49
|
+
}
|
|
50
|
+
return 'default';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getStatusColor(status: number): string {
|
|
54
|
+
if (status >= 200 && status < 300) return 'text-green-500';
|
|
55
|
+
if (status >= 300 && status < 400) return 'text-blue-500';
|
|
56
|
+
if (status >= 400 && status < 500) return 'text-amber-500';
|
|
57
|
+
return 'text-red-500';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function EndpointDetailsPage() {
|
|
61
|
+
const [searchParams] = useSearchParams();
|
|
62
|
+
const method = searchParams.get('method') ?? '';
|
|
63
|
+
const path = searchParams.get('path') ?? '';
|
|
64
|
+
|
|
65
|
+
const [details, setDetails] = useState<EndpointDetails | null>(null);
|
|
66
|
+
const [recentRequests, setRecentRequests] = useState<RequestEntry[]>([]);
|
|
67
|
+
const [loading, setLoading] = useState(true);
|
|
68
|
+
const [error, setError] = useState<string | null>(null);
|
|
69
|
+
const [timeRange, setTimeRange] = useState<TimeRange>(() =>
|
|
70
|
+
createTimeRange('1h'),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const fetchData = useCallback(async () => {
|
|
74
|
+
if (!method || !path) {
|
|
75
|
+
setError('Missing method or path');
|
|
76
|
+
setLoading(false);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
setLoading(true);
|
|
82
|
+
const timeRangeParams = {
|
|
83
|
+
start: timeRange.start.toISOString(),
|
|
84
|
+
end: timeRange.end.toISOString(),
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const [detailsData, requestsData] = await Promise.all([
|
|
88
|
+
getEndpointDetails(method, path, timeRangeParams),
|
|
89
|
+
getRequests({ method, search: path, limit: 20 }),
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
setDetails(detailsData);
|
|
93
|
+
setRecentRequests(requestsData);
|
|
94
|
+
setError(null);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
setError(
|
|
97
|
+
err instanceof Error ? err.message : 'Failed to load endpoint details',
|
|
98
|
+
);
|
|
99
|
+
} finally {
|
|
100
|
+
setLoading(false);
|
|
101
|
+
}
|
|
102
|
+
}, [method, path, timeRange.start, timeRange.end]);
|
|
103
|
+
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
fetchData();
|
|
106
|
+
}, [fetchData]);
|
|
107
|
+
|
|
108
|
+
if (!method || !path) {
|
|
109
|
+
return (
|
|
110
|
+
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
|
111
|
+
Missing endpoint method or path
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (loading && !details) {
|
|
117
|
+
return (
|
|
118
|
+
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
|
119
|
+
Loading endpoint details...
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (error) {
|
|
125
|
+
return (
|
|
126
|
+
<div className="flex-1 flex items-center justify-center text-red-500">
|
|
127
|
+
{error}
|
|
128
|
+
</div>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<div className="p-6 space-y-6">
|
|
134
|
+
{/* Header */}
|
|
135
|
+
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
|
136
|
+
<div className="flex items-center gap-4">
|
|
137
|
+
<Link
|
|
138
|
+
to="/performance"
|
|
139
|
+
className="p-2 rounded-md hover:bg-muted transition-colors"
|
|
140
|
+
>
|
|
141
|
+
<ArrowLeft className="w-5 h-5" />
|
|
142
|
+
</Link>
|
|
143
|
+
<div>
|
|
144
|
+
<div className="flex items-center gap-2">
|
|
145
|
+
<Badge variant={getMethodVariant(method)}>{method}</Badge>
|
|
146
|
+
<h1 className="text-xl font-semibold font-mono">{path}</h1>
|
|
147
|
+
</div>
|
|
148
|
+
<p className="text-sm text-muted-foreground mt-1">
|
|
149
|
+
Last seen: {details ? formatTimestamp(details.lastSeen) : 'N/A'}
|
|
150
|
+
</p>
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
<TimeRangeSelector value={timeRange} onChange={setTimeRange} />
|
|
154
|
+
</div>
|
|
155
|
+
|
|
156
|
+
{details && (
|
|
157
|
+
<>
|
|
158
|
+
{/* Key Metrics */}
|
|
159
|
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
160
|
+
<MetricCard
|
|
161
|
+
title="Total Requests"
|
|
162
|
+
value={formatNumber(details.count)}
|
|
163
|
+
trend="neutral"
|
|
164
|
+
/>
|
|
165
|
+
<MetricCard
|
|
166
|
+
title="Avg Duration"
|
|
167
|
+
value={formatDuration(details.avgDuration)}
|
|
168
|
+
trend="neutral"
|
|
169
|
+
trendValue={`p95: ${formatDuration(details.p95Duration)}`}
|
|
170
|
+
/>
|
|
171
|
+
<MetricCard
|
|
172
|
+
title="Success Rate"
|
|
173
|
+
value={formatPercent(details.successRate)}
|
|
174
|
+
trend={
|
|
175
|
+
details.successRate >= 99
|
|
176
|
+
? 'up'
|
|
177
|
+
: details.successRate >= 95
|
|
178
|
+
? 'neutral'
|
|
179
|
+
: 'down'
|
|
180
|
+
}
|
|
181
|
+
/>
|
|
182
|
+
<MetricCard
|
|
183
|
+
title="Error Rate"
|
|
184
|
+
value={formatPercent(details.errorRate)}
|
|
185
|
+
trend={
|
|
186
|
+
details.errorRate <= 1
|
|
187
|
+
? 'up'
|
|
188
|
+
: details.errorRate <= 5
|
|
189
|
+
? 'neutral'
|
|
190
|
+
: 'down'
|
|
191
|
+
}
|
|
192
|
+
/>
|
|
193
|
+
</div>
|
|
194
|
+
|
|
195
|
+
{/* Charts Row */}
|
|
196
|
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
197
|
+
{/* Status Distribution */}
|
|
198
|
+
<div className="bg-card rounded-lg border p-4">
|
|
199
|
+
<h2 className="text-sm font-medium mb-4">Status Distribution</h2>
|
|
200
|
+
<StatusDistributionChart data={details.statusDistribution} />
|
|
201
|
+
</div>
|
|
202
|
+
|
|
203
|
+
{/* Latency Percentiles */}
|
|
204
|
+
<div className="bg-card rounded-lg border p-4">
|
|
205
|
+
<h2 className="text-sm font-medium mb-4">Latency Percentiles</h2>
|
|
206
|
+
<LatencyPercentilesChart
|
|
207
|
+
p50={details.p50Duration}
|
|
208
|
+
p95={details.p95Duration}
|
|
209
|
+
p99={details.p99Duration}
|
|
210
|
+
/>
|
|
211
|
+
</div>
|
|
212
|
+
</div>
|
|
213
|
+
|
|
214
|
+
{/* Request Volume Time Series */}
|
|
215
|
+
<div className="bg-card rounded-lg border p-4">
|
|
216
|
+
<h2 className="text-sm font-medium mb-4">Request Volume</h2>
|
|
217
|
+
<AreaTimeSeriesChart
|
|
218
|
+
data={details.timeSeries.map((p) => ({
|
|
219
|
+
timestamp: p.timestamp,
|
|
220
|
+
value: p.count,
|
|
221
|
+
secondaryValue: p.errorCount,
|
|
222
|
+
}))}
|
|
223
|
+
primaryLabel="Requests"
|
|
224
|
+
secondaryLabel="Errors"
|
|
225
|
+
primaryColor="blue"
|
|
226
|
+
secondaryColor="red"
|
|
227
|
+
/>
|
|
228
|
+
</div>
|
|
229
|
+
</>
|
|
230
|
+
)}
|
|
231
|
+
|
|
232
|
+
{/* Recent Requests */}
|
|
233
|
+
<div className="bg-card rounded-lg border">
|
|
234
|
+
<div className="p-4 border-b">
|
|
235
|
+
<h2 className="text-sm font-medium">Recent Requests</h2>
|
|
236
|
+
</div>
|
|
237
|
+
<div className="overflow-x-auto">
|
|
238
|
+
<table className="w-full text-sm">
|
|
239
|
+
<thead className="bg-muted/50">
|
|
240
|
+
<tr>
|
|
241
|
+
<th className="text-left px-4 py-2 font-medium">Status</th>
|
|
242
|
+
<th className="text-left px-4 py-2 font-medium">Duration</th>
|
|
243
|
+
<th className="text-left px-4 py-2 font-medium">Timestamp</th>
|
|
244
|
+
<th className="text-left px-4 py-2 font-medium">ID</th>
|
|
245
|
+
</tr>
|
|
246
|
+
</thead>
|
|
247
|
+
<tbody>
|
|
248
|
+
{recentRequests.length === 0 ? (
|
|
249
|
+
<tr>
|
|
250
|
+
<td
|
|
251
|
+
colSpan={4}
|
|
252
|
+
className="text-center py-8 text-muted-foreground"
|
|
253
|
+
>
|
|
254
|
+
No recent requests
|
|
255
|
+
</td>
|
|
256
|
+
</tr>
|
|
257
|
+
) : (
|
|
258
|
+
recentRequests.map((req) => (
|
|
259
|
+
<tr key={req.id} className="border-t hover:bg-muted/30">
|
|
260
|
+
<td className="px-4 py-2">
|
|
261
|
+
<span className={getStatusColor(req.status)}>
|
|
262
|
+
{req.status}
|
|
263
|
+
</span>
|
|
264
|
+
</td>
|
|
265
|
+
<td className="px-4 py-2 font-mono">
|
|
266
|
+
{formatDuration(req.duration)}
|
|
267
|
+
</td>
|
|
268
|
+
<td className="px-4 py-2 text-muted-foreground">
|
|
269
|
+
{formatTimestamp(req.timestamp)}
|
|
270
|
+
</td>
|
|
271
|
+
<td className="px-4 py-2">
|
|
272
|
+
<Link
|
|
273
|
+
to={`/monitoring/requests/${req.id}`}
|
|
274
|
+
className="font-mono text-xs text-primary hover:underline"
|
|
275
|
+
>
|
|
276
|
+
{req.id.slice(0, 8)}...
|
|
277
|
+
</Link>
|
|
278
|
+
</td>
|
|
279
|
+
</tr>
|
|
280
|
+
))
|
|
281
|
+
)}
|
|
282
|
+
</tbody>
|
|
283
|
+
</table>
|
|
284
|
+
</div>
|
|
285
|
+
</div>
|
|
286
|
+
</div>
|
|
287
|
+
);
|
|
288
|
+
}
|