itsi 0.2.16 → 0.2.18

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 (170) hide show
  1. checksums.yaml +4 -4
  2. data/.zed/settings.json +32 -0
  3. data/CHANGELOG.md +21 -0
  4. data/Cargo.lock +4 -2
  5. data/crates/itsi_acme/Cargo.toml +1 -1
  6. data/crates/itsi_scheduler/Cargo.toml +1 -1
  7. data/crates/itsi_server/Cargo.toml +3 -1
  8. data/crates/itsi_server/src/lib.rs +6 -1
  9. data/crates/itsi_server/src/ruby_types/itsi_body_proxy/mod.rs +2 -0
  10. data/crates/itsi_server/src/ruby_types/itsi_grpc_call.rs +4 -4
  11. data/crates/itsi_server/src/ruby_types/itsi_grpc_response_stream/mod.rs +14 -13
  12. data/crates/itsi_server/src/ruby_types/itsi_http_request.rs +64 -33
  13. data/crates/itsi_server/src/ruby_types/itsi_http_response.rs +151 -152
  14. data/crates/itsi_server/src/ruby_types/itsi_server/file_watcher.rs +422 -110
  15. data/crates/itsi_server/src/ruby_types/itsi_server/itsi_server_config.rs +62 -15
  16. data/crates/itsi_server/src/ruby_types/itsi_server.rs +1 -1
  17. data/crates/itsi_server/src/server/binds/listener.rs +45 -7
  18. data/crates/itsi_server/src/server/frame_stream.rs +142 -0
  19. data/crates/itsi_server/src/server/http_message_types.rs +142 -9
  20. data/crates/itsi_server/src/server/io_stream.rs +28 -5
  21. data/crates/itsi_server/src/server/lifecycle_event.rs +1 -1
  22. data/crates/itsi_server/src/server/middleware_stack/middlewares/auth_basic.rs +2 -3
  23. data/crates/itsi_server/src/server/middleware_stack/middlewares/compression.rs +8 -10
  24. data/crates/itsi_server/src/server/middleware_stack/middlewares/cors.rs +2 -3
  25. data/crates/itsi_server/src/server/middleware_stack/middlewares/csp.rs +3 -3
  26. data/crates/itsi_server/src/server/middleware_stack/middlewares/error_response/default_responses.rs +54 -56
  27. data/crates/itsi_server/src/server/middleware_stack/middlewares/error_response.rs +5 -7
  28. data/crates/itsi_server/src/server/middleware_stack/middlewares/etag.rs +5 -5
  29. data/crates/itsi_server/src/server/middleware_stack/middlewares/proxy.rs +7 -10
  30. data/crates/itsi_server/src/server/middleware_stack/middlewares/redirect.rs +2 -3
  31. data/crates/itsi_server/src/server/middleware_stack/middlewares/static_assets.rs +1 -2
  32. data/crates/itsi_server/src/server/middleware_stack/middlewares/static_response.rs +4 -6
  33. data/crates/itsi_server/src/server/mod.rs +1 -0
  34. data/crates/itsi_server/src/server/process_worker.rs +3 -4
  35. data/crates/itsi_server/src/server/serve_strategy/acceptor.rs +16 -12
  36. data/crates/itsi_server/src/server/serve_strategy/cluster_mode.rs +83 -31
  37. data/crates/itsi_server/src/server/serve_strategy/single_mode.rs +166 -142
  38. data/crates/itsi_server/src/server/signal.rs +37 -9
  39. data/crates/itsi_server/src/server/thread_worker.rs +84 -69
  40. data/crates/itsi_server/src/services/itsi_http_service.rs +43 -43
  41. data/crates/itsi_server/src/services/static_file_server.rs +28 -47
  42. data/docs/benchmark-dashboard/.gitignore +27 -0
  43. data/docs/benchmark-dashboard/app/api/benchmarks/route.ts +22 -0
  44. data/docs/benchmark-dashboard/app/globals.css +94 -0
  45. data/docs/benchmark-dashboard/app/layout.tsx +20 -0
  46. data/docs/benchmark-dashboard/app/page.tsx +252 -0
  47. data/docs/benchmark-dashboard/components/benchmark-dashboard.tsx +1663 -0
  48. data/docs/benchmark-dashboard/components/theme-provider.tsx +11 -0
  49. data/docs/benchmark-dashboard/components/ui/accordion.tsx +58 -0
  50. data/docs/benchmark-dashboard/components/ui/alert-dialog.tsx +141 -0
  51. data/docs/benchmark-dashboard/components/ui/alert.tsx +59 -0
  52. data/docs/benchmark-dashboard/components/ui/aspect-ratio.tsx +7 -0
  53. data/docs/benchmark-dashboard/components/ui/avatar.tsx +50 -0
  54. data/docs/benchmark-dashboard/components/ui/badge.tsx +36 -0
  55. data/docs/benchmark-dashboard/components/ui/breadcrumb.tsx +115 -0
  56. data/docs/benchmark-dashboard/components/ui/button.tsx +56 -0
  57. data/docs/benchmark-dashboard/components/ui/calendar.tsx +66 -0
  58. data/docs/benchmark-dashboard/components/ui/card.tsx +79 -0
  59. data/docs/benchmark-dashboard/components/ui/carousel.tsx +262 -0
  60. data/docs/benchmark-dashboard/components/ui/chart.tsx +365 -0
  61. data/docs/benchmark-dashboard/components/ui/checkbox.tsx +30 -0
  62. data/docs/benchmark-dashboard/components/ui/collapsible.tsx +11 -0
  63. data/docs/benchmark-dashboard/components/ui/command.tsx +153 -0
  64. data/docs/benchmark-dashboard/components/ui/context-menu.tsx +200 -0
  65. data/docs/benchmark-dashboard/components/ui/dialog.tsx +122 -0
  66. data/docs/benchmark-dashboard/components/ui/drawer.tsx +118 -0
  67. data/docs/benchmark-dashboard/components/ui/dropdown-menu.tsx +200 -0
  68. data/docs/benchmark-dashboard/components/ui/form.tsx +178 -0
  69. data/docs/benchmark-dashboard/components/ui/hover-card.tsx +29 -0
  70. data/docs/benchmark-dashboard/components/ui/input-otp.tsx +71 -0
  71. data/docs/benchmark-dashboard/components/ui/input.tsx +22 -0
  72. data/docs/benchmark-dashboard/components/ui/label.tsx +26 -0
  73. data/docs/benchmark-dashboard/components/ui/loading-spinner.tsx +12 -0
  74. data/docs/benchmark-dashboard/components/ui/menubar.tsx +236 -0
  75. data/docs/benchmark-dashboard/components/ui/navigation-menu.tsx +128 -0
  76. data/docs/benchmark-dashboard/components/ui/pagination.tsx +117 -0
  77. data/docs/benchmark-dashboard/components/ui/popover.tsx +31 -0
  78. data/docs/benchmark-dashboard/components/ui/progress.tsx +28 -0
  79. data/docs/benchmark-dashboard/components/ui/radio-group.tsx +44 -0
  80. data/docs/benchmark-dashboard/components/ui/resizable.tsx +45 -0
  81. data/docs/benchmark-dashboard/components/ui/scroll-area.tsx +48 -0
  82. data/docs/benchmark-dashboard/components/ui/select.tsx +160 -0
  83. data/docs/benchmark-dashboard/components/ui/separator.tsx +31 -0
  84. data/docs/benchmark-dashboard/components/ui/sheet.tsx +140 -0
  85. data/docs/benchmark-dashboard/components/ui/sidebar.tsx +763 -0
  86. data/docs/benchmark-dashboard/components/ui/skeleton.tsx +15 -0
  87. data/docs/benchmark-dashboard/components/ui/slider.tsx +28 -0
  88. data/docs/benchmark-dashboard/components/ui/sonner.tsx +31 -0
  89. data/docs/benchmark-dashboard/components/ui/switch.tsx +29 -0
  90. data/docs/benchmark-dashboard/components/ui/table.tsx +117 -0
  91. data/docs/benchmark-dashboard/components/ui/tabs.tsx +55 -0
  92. data/docs/benchmark-dashboard/components/ui/textarea.tsx +22 -0
  93. data/docs/benchmark-dashboard/components/ui/toast.tsx +129 -0
  94. data/docs/benchmark-dashboard/components/ui/toaster.tsx +35 -0
  95. data/docs/benchmark-dashboard/components/ui/toggle-group.tsx +61 -0
  96. data/docs/benchmark-dashboard/components/ui/toggle.tsx +45 -0
  97. data/docs/benchmark-dashboard/components/ui/tooltip.tsx +30 -0
  98. data/docs/benchmark-dashboard/components/ui/use-mobile.tsx +19 -0
  99. data/docs/benchmark-dashboard/components/ui/use-toast.ts +194 -0
  100. data/docs/benchmark-dashboard/components.json +21 -0
  101. data/docs/benchmark-dashboard/dist/benchmark-dashboard.css +1 -0
  102. data/docs/benchmark-dashboard/dist/benchmark-dashboard.iife.js +211 -0
  103. data/docs/benchmark-dashboard/dist/placeholder-logo.png +0 -0
  104. data/docs/benchmark-dashboard/dist/placeholder-logo.svg +1 -0
  105. data/docs/benchmark-dashboard/dist/placeholder-user.jpg +0 -0
  106. data/docs/benchmark-dashboard/dist/placeholder.jpg +0 -0
  107. data/docs/benchmark-dashboard/dist/placeholder.svg +1 -0
  108. data/docs/benchmark-dashboard/embed.tsx +13 -0
  109. data/docs/benchmark-dashboard/hooks/use-mobile.tsx +19 -0
  110. data/docs/benchmark-dashboard/hooks/use-toast.ts +194 -0
  111. data/docs/benchmark-dashboard/lib/benchmark-utils.ts +54 -0
  112. data/docs/benchmark-dashboard/lib/utils.ts +6 -0
  113. data/docs/benchmark-dashboard/next.config.mjs +14 -0
  114. data/docs/benchmark-dashboard/package-lock.json +5859 -0
  115. data/docs/benchmark-dashboard/package.json +72 -0
  116. data/docs/benchmark-dashboard/pnpm-lock.yaml +5 -0
  117. data/docs/benchmark-dashboard/postcss.config.mjs +8 -0
  118. data/docs/benchmark-dashboard/styles/globals.css +94 -0
  119. data/docs/benchmark-dashboard/tailwind.config.ts +96 -0
  120. data/docs/benchmark-dashboard/tsconfig.json +27 -0
  121. data/docs/benchmark-dashboard/vite.config.ts +24 -0
  122. data/docs/build.rb +52 -0
  123. data/docs/content/acknowledgements/_index.md +1 -1
  124. data/docs/content/benchmarks/index.md +96 -0
  125. data/docs/content/configuration/_index.md +2 -2
  126. data/docs/content/getting_started/_index.md +76 -46
  127. data/docs/content/itsi_scheduler/_index.md +2 -2
  128. data/docs/hugo.yaml +10 -1
  129. data/docs/static/results.json +1 -0
  130. data/docs/static/scripts/benchmark-dashboard.iife.js +211 -0
  131. data/docs/static/styles/benchmark-dashboard.css +1 -0
  132. data/examples/api_with_schema_and_controllers/README.md +1 -1
  133. data/gems/scheduler/Cargo.lock +1 -1
  134. data/gems/scheduler/lib/itsi/scheduler/version.rb +1 -1
  135. data/gems/server/Cargo.lock +3 -1
  136. data/gems/server/exe/itsi +8 -5
  137. data/gems/server/lib/itsi/http_request.rb +31 -39
  138. data/gems/server/lib/itsi/http_response.rb +5 -0
  139. data/gems/server/lib/itsi/rack_env_pool.rb +59 -0
  140. data/gems/server/lib/itsi/server/config/dsl.rb +6 -4
  141. data/gems/server/lib/itsi/server/config/middleware/compression.md +3 -3
  142. data/gems/server/lib/itsi/server/config/middleware/endpoint/controller.md +1 -1
  143. data/gems/server/lib/itsi/server/config/middleware/proxy.md +2 -2
  144. data/gems/server/lib/itsi/server/config/middleware/proxy.rb +1 -1
  145. data/gems/server/lib/itsi/server/config/middleware/rackup_file.rb +2 -2
  146. data/gems/server/lib/itsi/server/config/options/auto_reload_config.rb +11 -6
  147. data/gems/server/lib/itsi/server/config/options/include.md +1 -0
  148. data/gems/server/lib/itsi/server/config/options/include.rb +13 -8
  149. data/gems/server/lib/itsi/server/config/options/pipeline_flush.md +16 -0
  150. data/gems/server/lib/itsi/server/config/options/pipeline_flush.rb +19 -0
  151. data/gems/server/lib/itsi/server/config/options/reuse_port.rb +2 -4
  152. data/gems/server/lib/itsi/server/config/options/writev.md +25 -0
  153. data/gems/server/lib/itsi/server/config/options/writev.rb +19 -0
  154. data/gems/server/lib/itsi/server/config.rb +22 -9
  155. data/gems/server/lib/itsi/server/default_config/Itsi.rb +9 -8
  156. data/gems/server/lib/itsi/server/grpc/grpc_call.rb +2 -0
  157. data/gems/server/lib/itsi/server/grpc/grpc_interface.rb +2 -2
  158. data/gems/server/lib/itsi/server/rack/handler/itsi.rb +3 -1
  159. data/gems/server/lib/itsi/server/rack_interface.rb +17 -12
  160. data/gems/server/lib/itsi/server/scheduler_interface.rb +2 -0
  161. data/gems/server/lib/itsi/server/version.rb +1 -1
  162. data/gems/server/lib/itsi/server.rb +1 -0
  163. data/gems/server/lib/ruby_lsp/itsi/addon.rb +12 -13
  164. data/gems/server/test/helpers/test_helper.rb +12 -13
  165. data/gems/server/test/middleware/grpc/grpc.rb +13 -14
  166. data/gems/server/test/middleware/grpc/test_service_impl.rb +3 -3
  167. data/gems/server/test/middleware/proxy.rb +262 -268
  168. data/lib/itsi/version.rb +1 -1
  169. metadata +97 -6
  170. data/tasks.txt +0 -28
@@ -0,0 +1,1663 @@
1
+ "use client";
2
+
3
+ import type React from "react";
4
+
5
+ import { useState, useMemo, useCallback, useEffect } from "react";
6
+ import {
7
+ Bar,
8
+ XAxis,
9
+ YAxis,
10
+ CartesianGrid,
11
+ ResponsiveContainer,
12
+ BarChart,
13
+ } from "recharts";
14
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
15
+ import {
16
+ Select,
17
+ SelectContent,
18
+ SelectItem,
19
+ SelectTrigger,
20
+ SelectValue,
21
+ SelectGroup,
22
+ SelectLabel,
23
+ } from "@/components/ui/select";
24
+ import { Label } from "@/components/ui/label";
25
+ import { Badge } from "@/components/ui/badge";
26
+ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
27
+ import {
28
+ InfoIcon,
29
+ TrendingUp,
30
+ TrendingDown,
31
+ TagIcon,
32
+ TrophyIcon,
33
+ } from "lucide-react";
34
+
35
+ // Updated types to match the new hierarchical structure with groups
36
+ type BenchmarkResult = {
37
+ server: string;
38
+ version?: string;
39
+ test_case: string;
40
+ threads: number;
41
+ workers: number;
42
+ http2: boolean;
43
+ concurrency: number;
44
+ rss_mb?: number;
45
+ results: {
46
+ successRate: number;
47
+ total: number;
48
+ slowest: number;
49
+ fastest: number;
50
+ average: number;
51
+ requestsPerSec: number;
52
+ totalData: number;
53
+ sizePerRequest: number;
54
+ sizePerSec: number;
55
+ errorDistribution: Record<string, number>;
56
+ p95_latency: number;
57
+ };
58
+ timestamp?: string;
59
+ };
60
+
61
+ type ServerData = {
62
+ server: string;
63
+ results: BenchmarkResult[];
64
+ };
65
+
66
+ type TestData = {
67
+ test: string;
68
+ servers: ServerData[];
69
+ };
70
+
71
+ type GroupData = {
72
+ group: string;
73
+ tests: TestData[];
74
+ };
75
+
76
+ type CpuData = {
77
+ cpu: string;
78
+ groups: GroupData[];
79
+ };
80
+
81
+ type HierarchicalBenchmarkData = CpuData[];
82
+
83
+ type FilterOptions = {
84
+ cpus: string[];
85
+ testCases: string[];
86
+ servers: string[];
87
+ threads: number[];
88
+ workers: number[];
89
+ concurrencyLevels: number[];
90
+ http2Options: (boolean | "all")[];
91
+ };
92
+
93
+ type FilterState = {
94
+ cpu: string;
95
+ testCase: string;
96
+ threads: number;
97
+ workers: number;
98
+ concurrency: number;
99
+ http2: boolean | "all";
100
+ xAxis: string;
101
+ metric: string;
102
+ visibleServers: string[];
103
+ };
104
+
105
+ type BenchmarkDashboardProps = {
106
+ data: HierarchicalBenchmarkData;
107
+ };
108
+
109
+ export function BenchmarkDashboard({ data }: BenchmarkDashboardProps) {
110
+ // Helper function to format server names (replace __ with +)
111
+ const formatServerName = useCallback((serverName: string): string => {
112
+ return serverName.replace(/__/g, "+");
113
+ }, []);
114
+
115
+ // Flatten the hierarchical data into the format we need for processing
116
+ const flattenedData = useMemo(() => {
117
+ const flattened: BenchmarkResult[] = [];
118
+
119
+ if (!data || !Array.isArray(data)) {
120
+ return flattened;
121
+ }
122
+
123
+ data.forEach((cpuData) => {
124
+ if (!cpuData?.groups || !Array.isArray(cpuData.groups)) {
125
+ return;
126
+ }
127
+
128
+ cpuData.groups.forEach((groupData) => {
129
+ if (!groupData?.tests || !Array.isArray(groupData.tests)) {
130
+ return;
131
+ }
132
+
133
+ groupData.tests.forEach((testData) => {
134
+ if (!testData?.servers || !Array.isArray(testData.servers)) {
135
+ return;
136
+ }
137
+
138
+ testData.servers.forEach((serverData) => {
139
+ if (!serverData?.results || !Array.isArray(serverData.results)) {
140
+ return;
141
+ }
142
+
143
+ serverData.results.forEach((result) => {
144
+ // Add CPU and group information to each result
145
+ flattened.push({
146
+ ...result,
147
+ cpu: cpuData.cpu,
148
+ group: groupData.group,
149
+ } as BenchmarkResult & { cpu: string; group: string });
150
+ });
151
+ });
152
+ });
153
+ });
154
+ });
155
+
156
+ return flattened;
157
+ }, [data]);
158
+
159
+ // Extract all possible filter options from hierarchical data
160
+ const allFilterOptions: FilterOptions = useMemo(() => {
161
+ const defaultOptions: FilterOptions = {
162
+ cpus: [],
163
+ testCases: [],
164
+ servers: [],
165
+ threads: [],
166
+ workers: [],
167
+ concurrencyLevels: [],
168
+ http2Options: ["all"],
169
+ };
170
+
171
+ if (!data || !Array.isArray(data) || data.length === 0) {
172
+ return defaultOptions;
173
+ }
174
+
175
+ return {
176
+ cpus: data.map((cpuData) => cpuData.cpu).filter(Boolean),
177
+ testCases: [
178
+ ...new Set(flattenedData.map((item) => item.test_case).filter(Boolean)),
179
+ ],
180
+ servers: [
181
+ ...new Set(flattenedData.map((item) => item.server).filter(Boolean)),
182
+ ],
183
+ threads: [
184
+ ...new Set(
185
+ flattenedData
186
+ .map((item) => item.threads)
187
+ .filter((t) => typeof t === "number"),
188
+ ),
189
+ ].sort((a, b) => a - b),
190
+ workers: [
191
+ ...new Set(
192
+ flattenedData
193
+ .map((item) => item.workers)
194
+ .filter((w) => typeof w === "number"),
195
+ ),
196
+ ].sort((a, b) => a - b),
197
+ concurrencyLevels: [
198
+ ...new Set(
199
+ flattenedData
200
+ .map((item) => item.concurrency)
201
+ .filter((c) => typeof c === "number"),
202
+ ),
203
+ ].sort((a, b) => a - b),
204
+ http2Options: [
205
+ "all",
206
+ ...new Set(
207
+ flattenedData
208
+ .map((item) => item.http2)
209
+ .filter((h) => typeof h === "boolean"),
210
+ ),
211
+ ],
212
+ };
213
+ }, [data, flattenedData]);
214
+
215
+ // Generate consistent colors for all servers upfront
216
+ const serverColors = useMemo(() => {
217
+ const itsiColor = "#ff7f0e";
218
+
219
+ const palette = [
220
+ "#1f77b4", // blue
221
+ "#2ca02c", // green
222
+ "#d62728", // red (not orange)
223
+ "#9467bd", // purple
224
+ "#8c564b", // brown
225
+ "#e377c2", // pink
226
+ "#7f7f7f", // gray
227
+ "#bcbd22", // lime
228
+ "#17becf", // cyan
229
+ "#393b79", // indigo
230
+ "#a55194", // magenta
231
+ ];
232
+
233
+ const colorMap: Record<string, string> = {};
234
+ const servers = [...allFilterOptions.servers];
235
+
236
+ const itsiIndex = servers.findIndex((s) => s === "itsi");
237
+ if (itsiIndex !== -1) {
238
+ colorMap["itsi"] = itsiColor;
239
+ servers.splice(itsiIndex, 1);
240
+ }
241
+
242
+ servers.forEach((server, index) => {
243
+ colorMap[server] = palette[index % palette.length];
244
+ });
245
+
246
+ return colorMap;
247
+ }, [allFilterOptions.servers]);
248
+
249
+ // Helper function to parse URL search parameters
250
+ const parseUrlParams = useCallback((): Partial<FilterState> | null => {
251
+ if (typeof window === "undefined") return null;
252
+
253
+ try {
254
+ const urlParams = new URLSearchParams(window.location.search);
255
+ const filters: Partial<FilterState> = {};
256
+
257
+ // Parse each parameter
258
+ const cpu = urlParams.get("cpu");
259
+ const testCase = urlParams.get("testCase");
260
+ const threads = urlParams.get("threads");
261
+ const workers = urlParams.get("workers");
262
+ const concurrency = urlParams.get("concurrency");
263
+ const http2 = urlParams.get("http2");
264
+ const xAxis = urlParams.get("xAxis");
265
+ const metric = urlParams.get("metric");
266
+
267
+ if (cpu) filters.cpu = cpu;
268
+ if (testCase) filters.testCase = testCase;
269
+ if (threads) filters.threads = Number.parseInt(threads);
270
+ if (workers) filters.workers = Number.parseInt(workers);
271
+ if (concurrency) filters.concurrency = Number.parseInt(concurrency);
272
+ if (http2) {
273
+ if (http2 === "all") {
274
+ filters.http2 = "all";
275
+ } else {
276
+ filters.http2 = http2 === "true";
277
+ }
278
+ }
279
+ if (xAxis) filters.xAxis = xAxis;
280
+ if (metric) filters.metric = metric;
281
+
282
+ const visibleServersParam = urlParams.get("visibleServers");
283
+ if (visibleServersParam) {
284
+ try {
285
+ filters.visibleServers = visibleServersParam
286
+ .split(",")
287
+ .filter(Boolean);
288
+ } catch (e) {
289
+ // Ignore parsing errors
290
+ }
291
+ }
292
+
293
+ return Object.keys(filters).length > 0 ? filters : null;
294
+ } catch (error) {
295
+ console.warn("Failed to parse URL parameters:", error);
296
+ }
297
+
298
+ return null;
299
+ }, []);
300
+
301
+ // Helper function to update URL search parameters
302
+ const updateUrlParams = useCallback((filters: FilterState) => {
303
+ if (typeof window === "undefined") return;
304
+
305
+ try {
306
+ const url = new URL(window.location.href);
307
+
308
+ // Set each parameter
309
+ url.searchParams.set("cpu", filters.cpu);
310
+ url.searchParams.set("testCase", filters.testCase);
311
+ url.searchParams.set("threads", filters.threads.toString());
312
+ url.searchParams.set("workers", filters.workers.toString());
313
+ url.searchParams.set("concurrency", filters.concurrency.toString());
314
+ url.searchParams.set("http2", filters.http2.toString());
315
+ url.searchParams.set("xAxis", filters.xAxis);
316
+ url.searchParams.set("metric", filters.metric);
317
+
318
+ // Set visible servers as comma-separated list
319
+ if (filters.visibleServers && filters.visibleServers.length > 0) {
320
+ url.searchParams.set(
321
+ "visibleServers",
322
+ filters.visibleServers.join(","),
323
+ );
324
+ } else {
325
+ url.searchParams.delete("visibleServers");
326
+ }
327
+
328
+ // Update the URL without triggering a page reload
329
+ window.history.replaceState(null, "", url.toString());
330
+ } catch (error) {
331
+ console.warn("Failed to update URL parameters:", error);
332
+ }
333
+ }, []);
334
+
335
+ // Helper function to validate and sanitize filter state from URL
336
+ const validateAndSanitizeFilters = useCallback(
337
+ (urlFilters: Partial<FilterState>): FilterState => {
338
+ const defaultFilters: FilterState = {
339
+ cpu: allFilterOptions.cpus[0] || "",
340
+ testCase: allFilterOptions.testCases[0] || "",
341
+ threads: allFilterOptions.threads[0] || 1,
342
+ workers: allFilterOptions.workers[0] || 1,
343
+ concurrency: allFilterOptions.concurrencyLevels[0] || 10,
344
+ http2: "all", // Default to "all"
345
+ xAxis: "concurrency",
346
+ metric: "rps",
347
+ visibleServers: allFilterOptions.servers, // Default to all servers visible
348
+ };
349
+
350
+ // Validate each field and fall back to defaults if invalid
351
+ const validatedFilters: FilterState = {
352
+ cpu: allFilterOptions.cpus.includes(urlFilters.cpu || "")
353
+ ? urlFilters.cpu!
354
+ : defaultFilters.cpu,
355
+ testCase: allFilterOptions.testCases.includes(urlFilters.testCase || "")
356
+ ? urlFilters.testCase!
357
+ : defaultFilters.testCase,
358
+ threads: allFilterOptions.threads.includes(urlFilters.threads || 0)
359
+ ? urlFilters.threads!
360
+ : defaultFilters.threads,
361
+ workers: allFilterOptions.workers.includes(urlFilters.workers || 0)
362
+ ? urlFilters.workers!
363
+ : defaultFilters.workers,
364
+ concurrency: allFilterOptions.concurrencyLevels.includes(
365
+ urlFilters.concurrency || 0,
366
+ )
367
+ ? urlFilters.concurrency!
368
+ : defaultFilters.concurrency,
369
+ http2: allFilterOptions.http2Options.includes(urlFilters.http2 as any)
370
+ ? (urlFilters.http2 as boolean | "all")
371
+ : defaultFilters.http2,
372
+ xAxis: ["concurrency", "threads", "workers"].includes(
373
+ urlFilters.xAxis || "",
374
+ )
375
+ ? urlFilters.xAxis!
376
+ : defaultFilters.xAxis,
377
+ metric: ["rps", "p95_latency", "errorRate"].includes(
378
+ urlFilters.metric || "",
379
+ )
380
+ ? urlFilters.metric!
381
+ : defaultFilters.metric,
382
+ visibleServers: Array.isArray(urlFilters.visibleServers)
383
+ ? urlFilters.visibleServers.filter((server) =>
384
+ allFilterOptions.servers.includes(server),
385
+ )
386
+ : defaultFilters.visibleServers,
387
+ };
388
+
389
+ return validatedFilters;
390
+ },
391
+ [allFilterOptions],
392
+ );
393
+
394
+ // Initialize filter state with URL parameters or defaults
395
+ const [filters, setFilters] = useState<FilterState>(() => {
396
+ const urlFilters = parseUrlParams();
397
+ if (urlFilters && allFilterOptions.cpus.length > 0) {
398
+ return validateAndSanitizeFilters(urlFilters);
399
+ }
400
+
401
+ const preferredTestCase = allFilterOptions.testCases.includes("hello_world")
402
+ ? "hello_world"
403
+ : allFilterOptions.testCases[0] || "";
404
+
405
+ return {
406
+ cpu: allFilterOptions.cpus[0] || "",
407
+ testCase: preferredTestCase || "",
408
+ threads: allFilterOptions.threads[0] || 1,
409
+ workers: allFilterOptions.workers[0] || 1,
410
+ concurrency: allFilterOptions.concurrencyLevels[0] || 10,
411
+ http2: "all", // Default to "all"
412
+ xAxis: "concurrency",
413
+ metric: "rps",
414
+ visibleServers: allFilterOptions.servers,
415
+ };
416
+ });
417
+
418
+ // Track which servers are visible
419
+ const [visibleServers, setVisibleServers] = useState<Record<string, boolean>>(
420
+ () => {
421
+ const initialVisibleServers: Record<string, boolean> = {};
422
+ allFilterOptions.servers.forEach((server) => {
423
+ initialVisibleServers[server] = true;
424
+ });
425
+
426
+ const urlFilters = parseUrlParams();
427
+ if (
428
+ urlFilters?.visibleServers &&
429
+ Array.isArray(urlFilters.visibleServers)
430
+ ) {
431
+ allFilterOptions.servers.forEach((server) => {
432
+ initialVisibleServers[server] =
433
+ urlFilters.visibleServers!.includes(server);
434
+ });
435
+ }
436
+
437
+ return initialVisibleServers;
438
+ },
439
+ );
440
+
441
+ // Currently hovered data point
442
+ const [hoveredPoint, setHoveredPoint] = useState<BenchmarkResult | null>(
443
+ null,
444
+ );
445
+ const [activeDataKey, setActiveDataKey] = useState<string | null>(null);
446
+
447
+ // Track legend interactions to disable animations
448
+ // const [isLegendInteracting, setIsLegendInteracting] = useState(false)
449
+
450
+ // Update URL parameters when filters change
451
+ useEffect(() => {
452
+ updateUrlParams(filters);
453
+ }, [filters, updateUrlParams]);
454
+
455
+ // Handle initial load from URL parameters after data is available
456
+ useEffect(() => {
457
+ if (allFilterOptions.cpus.length > 0) {
458
+ const urlFilters = parseUrlParams();
459
+ if (urlFilters) {
460
+ const validatedFilters = validateAndSanitizeFilters(urlFilters);
461
+ setFilters(validatedFilters);
462
+
463
+ // Update visibleServers based on URL params after filters are set
464
+ const initialVisibleServers: Record<string, boolean> = {};
465
+ allFilterOptions.servers.forEach((server) => {
466
+ initialVisibleServers[server] = true;
467
+ });
468
+
469
+ if (
470
+ urlFilters?.visibleServers &&
471
+ Array.isArray(urlFilters.visibleServers)
472
+ ) {
473
+ allFilterOptions.servers.forEach((server) => {
474
+ initialVisibleServers[server] =
475
+ urlFilters.visibleServers!.includes(server);
476
+ });
477
+ }
478
+ setVisibleServers(initialVisibleServers);
479
+ }
480
+ }
481
+ }, [allFilterOptions, parseUrlParams, validateAndSanitizeFilters]);
482
+
483
+ // Get dynamic filter options based on selected CPU and test case
484
+ const filterOptions = useMemo(() => {
485
+ // First filter by CPU
486
+ const cpuFilteredData = flattenedData.filter(
487
+ (item) => (item as any).cpu === filters.cpu,
488
+ );
489
+
490
+ // Get available test cases for the selected CPU
491
+ const availableTestCases = [
492
+ ...new Set(cpuFilteredData.map((item) => item.test_case).filter(Boolean)),
493
+ ];
494
+
495
+ // Then filter by test case to get the remaining filter options
496
+ const testCaseFilteredData = cpuFilteredData.filter(
497
+ (item) => item.test_case === filters.testCase,
498
+ );
499
+
500
+ return {
501
+ cpus: allFilterOptions.cpus,
502
+ testCases: availableTestCases,
503
+ servers: [
504
+ ...new Set(
505
+ testCaseFilteredData.map((item) => item.server).filter(Boolean),
506
+ ),
507
+ ],
508
+ threads: [
509
+ ...new Set(
510
+ testCaseFilteredData
511
+ .map((item) => item.threads)
512
+ .filter((t) => typeof t === "number"),
513
+ ),
514
+ ].sort((a, b) => a - b),
515
+ workers: [
516
+ ...new Set(
517
+ testCaseFilteredData
518
+ .map((item) => item.workers)
519
+ .filter((w) => typeof w === "number"),
520
+ ),
521
+ ].sort((a, b) => a - b),
522
+ concurrencyLevels: [
523
+ ...new Set(
524
+ testCaseFilteredData
525
+ .map((item) => item.concurrency)
526
+ .filter((c) => typeof c === "number"),
527
+ ),
528
+ ].sort((a, b) => a - b),
529
+ http2Options: [
530
+ "all",
531
+ ...new Set(
532
+ testCaseFilteredData
533
+ .map((item) => item.http2)
534
+ .filter((h) => typeof h === "boolean"),
535
+ ),
536
+ ],
537
+ };
538
+ }, [flattenedData, filters.cpu, filters.testCase, allFilterOptions.cpus]);
539
+
540
+ // Get grouped test cases for the dropdown
541
+ const groupedTestCases = useMemo(() => {
542
+ // First filter by CPU to get available data
543
+ const cpuFilteredData = flattenedData.filter(
544
+ (item) => (item as any).cpu === filters.cpu,
545
+ );
546
+
547
+ // Group test cases by their group
548
+ const groupedTests: Record<string, string[]> = {};
549
+ cpuFilteredData.forEach((item) => {
550
+ const group = (item as any).group;
551
+ if (!group || !item.test_case) return;
552
+
553
+ if (!groupedTests[group]) {
554
+ groupedTests[group] = [];
555
+ }
556
+ if (!groupedTests[group].includes(item.test_case)) {
557
+ groupedTests[group].push(item.test_case);
558
+ }
559
+ });
560
+
561
+ // Sort test cases within each group
562
+ Object.keys(groupedTests).forEach((group) => {
563
+ groupedTests[group].sort();
564
+ });
565
+
566
+ // Sort groups: "rack" first, then alphabetically
567
+ const sortedGroups = Object.keys(groupedTests).sort((a, b) => {
568
+ if (a === "rack") return -1;
569
+ if (b === "rack") return 1;
570
+ return a.localeCompare(b);
571
+ });
572
+
573
+ return { groupedTests, sortedGroups };
574
+ }, [flattenedData, filters.cpu]);
575
+
576
+ // Update filters when CPU or test case changes to ensure valid selections
577
+ useEffect(() => {
578
+ setFilters((prev) => {
579
+ const newFilters = { ...prev };
580
+
581
+ // If test case is not available for selected CPU, select first available
582
+ if (!filterOptions.testCases.includes(prev.testCase)) {
583
+ newFilters.testCase = filterOptions.testCases[0] || "";
584
+ }
585
+
586
+ // Update other filters with first available value if current value is not valid
587
+ // BUT skip the filter that matches the current X-axis
588
+ if (
589
+ prev.xAxis !== "threads" &&
590
+ !filterOptions.threads.includes(prev.threads)
591
+ ) {
592
+ newFilters.threads = filterOptions.threads[0] || 1;
593
+ }
594
+
595
+ if (
596
+ prev.xAxis !== "workers" &&
597
+ !filterOptions.workers.includes(prev.workers)
598
+ ) {
599
+ newFilters.workers = filterOptions.workers[0] || 1;
600
+ }
601
+
602
+ if (
603
+ prev.xAxis !== "concurrency" &&
604
+ !filterOptions.concurrencyLevels.includes(prev.concurrency)
605
+ ) {
606
+ newFilters.concurrency = filterOptions.concurrencyLevels[0] || 10;
607
+ }
608
+
609
+ if (!filterOptions.http2Options.includes(prev.http2)) {
610
+ newFilters.http2 = filterOptions.http2Options[0] || "all";
611
+ }
612
+
613
+ return newFilters;
614
+ });
615
+ }, [filters.cpu, filterOptions]);
616
+
617
+ // Apply filters to flattened data
618
+ const filteredData = useMemo(() => {
619
+ return flattenedData.filter((item) => {
620
+ const itemWithCpu = item as any;
621
+ if (itemWithCpu.cpu !== filters.cpu) return false;
622
+ if (item.test_case !== filters.testCase) return false;
623
+
624
+ // Don't filter by the parameter that's being used as X-axis
625
+ if (filters.xAxis !== "threads" && item.threads !== filters.threads)
626
+ return false;
627
+ if (filters.xAxis !== "workers" && item.workers !== filters.workers)
628
+ return false;
629
+ if (
630
+ filters.xAxis !== "concurrency" &&
631
+ item.concurrency !== filters.concurrency
632
+ )
633
+ return false;
634
+
635
+ // Handle "all" protocol option
636
+ if (filters.http2 !== "all" && item.http2 !== filters.http2) return false;
637
+
638
+ return true;
639
+ });
640
+ }, [flattenedData, filters]);
641
+
642
+ // Prepare data for chart based on selected x-axis
643
+ const chartData = useMemo(() => {
644
+ // Group data by the selected x-axis
645
+ const groupedByXAxis: Record<string, BenchmarkResult[]> = {};
646
+
647
+ filteredData.forEach((item) => {
648
+ const xAxisValue = String(item[filters.xAxis as keyof BenchmarkResult]);
649
+ if (!groupedByXAxis[xAxisValue]) {
650
+ groupedByXAxis[xAxisValue] = [];
651
+ }
652
+ groupedByXAxis[xAxisValue].push(item);
653
+ });
654
+
655
+ // Convert to format suitable for chart
656
+ return Object.entries(groupedByXAxis)
657
+ .map(([xAxisValue, items]) => {
658
+ const point: Record<string, any> = { [filters.xAxis]: xAxisValue };
659
+
660
+ // Group items by server and protocol when "all" is selected
661
+ items.forEach((item) => {
662
+ if (visibleServers[item.server]) {
663
+ // Single metric based on selection
664
+ let metricValue: number;
665
+ switch (filters.metric) {
666
+ case "rps":
667
+ metricValue = item.results.requestsPerSec;
668
+ break;
669
+ case "p95_latency":
670
+ metricValue = item.results.p95_latency;
671
+ break;
672
+ case "errorRate":
673
+ metricValue = 1 - item.results.successRate;
674
+ break;
675
+ default:
676
+ metricValue = item.results.requestsPerSec;
677
+ }
678
+
679
+ // Create unique key for server+protocol combination when showing all protocols
680
+ let dataKey: string;
681
+ if (filters.http2 === "all") {
682
+ const protocolSuffix = item.http2 ? " (HTTP/2)" : " (HTTP/1.1)";
683
+ dataKey = `${formatServerName(item.server)}${protocolSuffix}`;
684
+ } else {
685
+ dataKey = formatServerName(item.server);
686
+ }
687
+
688
+ point[dataKey] = metricValue;
689
+ // Store the full item for hover details
690
+ point[`${dataKey}_data`] = item;
691
+ }
692
+ });
693
+
694
+ return point;
695
+ })
696
+ .sort((a, b) => {
697
+ // Sort numerically if the x-axis is a number
698
+ const aVal = a[filters.xAxis];
699
+ const bVal = b[filters.xAxis];
700
+ if (!isNaN(Number(aVal)) && !isNaN(Number(bVal))) {
701
+ return Number(aVal) - Number(bVal);
702
+ }
703
+ // Otherwise sort alphabetically
704
+ return String(aVal).localeCompare(String(bVal));
705
+ });
706
+ }, [filteredData, filters, visibleServers, formatServerName]);
707
+
708
+ // Get metric info for display
709
+ const metricInfo = useMemo(() => {
710
+ // Helper function to format large numbers compactly
711
+ const formatCompact = (value: number, decimals = 1): string => {
712
+ if (value >= 1000000) {
713
+ return `${(value / 1000000).toFixed(decimals)}M`;
714
+ } else if (value >= 1000) {
715
+ return `${(value / 1000).toFixed(decimals)}K`;
716
+ }
717
+ return value.toFixed(decimals);
718
+ };
719
+
720
+ switch (filters.metric) {
721
+ case "rps":
722
+ return {
723
+ label: "Requests per Second",
724
+ isBetter: "higher",
725
+ icon: TrendingUp,
726
+ formatter: (value: number) => formatCompact(value, 1),
727
+ };
728
+ case "p95_latency":
729
+ return {
730
+ label: "P95 Latency (ms)",
731
+ isBetter: "lower",
732
+ icon: TrendingDown,
733
+ formatter: (value: number) =>
734
+ value >= 1000 ? formatCompact(value, 1) : value.toFixed(2),
735
+ };
736
+ case "errorRate":
737
+ return {
738
+ label: "Error Rate",
739
+ isBetter: "lower",
740
+ icon: TrendingDown,
741
+ formatter: (value: number) => `${(value * 100).toFixed(1)}%`,
742
+ };
743
+ default:
744
+ return {
745
+ label: "Requests per Second",
746
+ isBetter: "higher",
747
+ icon: TrendingUp,
748
+ formatter: (value: number) => formatCompact(value, 1),
749
+ };
750
+ }
751
+ }, [filters.metric]);
752
+
753
+ // Handle filter changes
754
+ const handleFilterChange = (key: keyof FilterState, value: any) => {
755
+ setFilters((prev) => ({ ...prev, [key]: value }));
756
+ };
757
+
758
+ // Toggle server visibility when clicking on legend
759
+ const handleLegendClick = useCallback(
760
+ (server: string, event?: React.MouseEvent) => {
761
+ // Disable animations during legend interaction
762
+ // setIsLegendInteracting(true)
763
+
764
+ // Check if Ctrl (Windows/Linux) or Cmd (Mac) key is pressed
765
+ const isExclusiveMode = event?.ctrlKey || event?.metaKey;
766
+
767
+ if (isExclusiveMode) {
768
+ // Ctrl/Cmd + click: Show only this server (hide all others)
769
+ const newVisibleServers: Record<string, boolean> = {};
770
+ allFilterOptions.servers.forEach((s) => {
771
+ newVisibleServers[s] = s === server;
772
+ });
773
+ setVisibleServers(newVisibleServers);
774
+ } else {
775
+ // Normal click: Toggle this server
776
+ setVisibleServers((prev) => ({
777
+ ...prev,
778
+ [server]: !prev[server],
779
+ }));
780
+ }
781
+
782
+ // Re-enable animations after a short delay
783
+ // setTimeout(() => {
784
+ // setIsLegendInteracting(false)
785
+ // }, 100)
786
+ },
787
+ [allFilterOptions.servers],
788
+ );
789
+
790
+ // Calculate summary statistics
791
+ const summaryStats = useMemo(() => {
792
+ if (filteredData.length === 0) return null;
793
+
794
+ const values = filteredData.map((item) => {
795
+ switch (filters.metric) {
796
+ case "rps":
797
+ return item.results.requestsPerSec;
798
+ case "p95_latency":
799
+ return item.results.p95_latency;
800
+ case "errorRate":
801
+ return 1 - item.results.successRate;
802
+ default:
803
+ return item.results.requestsPerSec;
804
+ }
805
+ });
806
+
807
+ return {
808
+ count: filteredData.length,
809
+ min: Math.min(...values),
810
+ max: Math.max(...values),
811
+ avg: values.reduce((sum, val) => sum + val, 0) / values.length,
812
+ };
813
+ }, [filteredData, filters.metric]);
814
+
815
+ const topPerformers = useMemo(() => {
816
+ if (filteredData.length === 0) return [];
817
+
818
+ const currentGroup = (filteredData[0] as any).group;
819
+ if (!currentGroup) return [];
820
+
821
+ const performanceMap: Record<
822
+ string,
823
+ Record<string, Record<boolean, number[]>>
824
+ > = {};
825
+
826
+ flattenedData
827
+ .filter((item) => {
828
+ const itemWithCpu = item as any;
829
+ return (
830
+ itemWithCpu.cpu === filters.cpu && itemWithCpu.group === currentGroup
831
+ );
832
+ })
833
+ .forEach((item) => {
834
+ const key = `${item.test_case}_${item.threads}_${item.workers}_${item.concurrency}`;
835
+
836
+ if (!performanceMap[key]) {
837
+ performanceMap[key] = {};
838
+ }
839
+
840
+ if (!performanceMap[key][item.server]) {
841
+ performanceMap[key][item.server] = { true: [], false: [] };
842
+ }
843
+
844
+ performanceMap[key][item.server][item.http2].push(
845
+ item.results.requestsPerSec,
846
+ );
847
+ });
848
+
849
+ const winCounts: Record<string, number> = {};
850
+
851
+ Object.values(performanceMap).forEach((serverResults) => {
852
+ const allScores: [string, number][] = [];
853
+
854
+ for (const [server, variants] of Object.entries(serverResults)) {
855
+ for (const [http2, values] of Object.entries(variants)) {
856
+ const parsedHttp2 = http2 === "true"; // keys are string
857
+ if (values.length > 0) {
858
+ const avg = values.reduce((sum, v) => sum + v, 0) / values.length;
859
+ allScores.push([`${server}___${parsedHttp2}`, avg]);
860
+ }
861
+ }
862
+ }
863
+
864
+ if (allScores.length === 0) return;
865
+
866
+ const bestScore = Math.max(...allScores.map(([, v]) => v));
867
+ const winningServers = new Set(
868
+ allScores
869
+ .filter(([, v]) => v === bestScore)
870
+ .map(([k]) => k.split("___")[0]), // extract server
871
+ );
872
+
873
+ for (const server of winningServers) {
874
+ winCounts[server] = (winCounts[server] || 0) + 1;
875
+ }
876
+ });
877
+
878
+ return Object.entries(winCounts)
879
+ .sort(([, a], [, b]) => b - a)
880
+ .slice(0, 8)
881
+ .map(([server, count]) => ({ server, count }));
882
+ }, [flattenedData, filters.cpu, filteredData]);
883
+
884
+ // Handle chart hover
885
+ const handleChartHover = (props: any) => {
886
+ if (props.activePayload && props.activePayload.length > 0) {
887
+ const { dataKey, payload } = props.activePayload[0];
888
+ const serverData = payload[`${dataKey}_data`];
889
+
890
+ if (serverData) {
891
+ setHoveredPoint(serverData);
892
+ setActiveDataKey(dataKey);
893
+ return;
894
+ }
895
+ }
896
+
897
+ setHoveredPoint(null);
898
+ setActiveDataKey(null);
899
+ };
900
+
901
+ // Get visible servers and their data keys for the chart
902
+ const visibleDataKeys = useMemo(() => {
903
+ const keys: string[] = [];
904
+
905
+ filterOptions.servers.forEach((server) => {
906
+ if (visibleServers[server]) {
907
+ if (filters.http2 === "all") {
908
+ // Check if this server has both HTTP/1.1 and HTTP/2 data
909
+ const serverData = filteredData.filter(
910
+ (item) => item.server === server,
911
+ );
912
+ const hasHttp1 = serverData.some((item) => !item.http2);
913
+ const hasHttp2 = serverData.some((item) => item.http2);
914
+
915
+ if (hasHttp1) keys.push(`${formatServerName(server)} (HTTP/1.1)`);
916
+ if (hasHttp2) keys.push(`${formatServerName(server)} (HTTP/2)`);
917
+ } else {
918
+ keys.push(formatServerName(server));
919
+ }
920
+ }
921
+ });
922
+
923
+ return keys;
924
+ }, [
925
+ filterOptions.servers,
926
+ visibleServers,
927
+ filters.http2,
928
+ filteredData,
929
+ formatServerName,
930
+ ]);
931
+
932
+ // Get protocol display text
933
+ const getProtocolDisplay = () => {
934
+ if (filters.http2 === "all") return "All Protocols";
935
+ return filters.http2 ? "HTTP/2" : "HTTP/1.1";
936
+ };
937
+
938
+ // Show loading state if no data
939
+ const visibleServersForUrl = useMemo(() => {
940
+ return Object.keys(visibleServers).filter(
941
+ (server) => visibleServers[server],
942
+ );
943
+ }, [visibleServers]);
944
+
945
+ // Update URL parameters when visible servers change
946
+ useEffect(() => {
947
+ updateUrlParams({ ...filters, visibleServers: visibleServersForUrl });
948
+ }, [visibleServersForUrl, filters, updateUrlParams]);
949
+
950
+ if (!data || !Array.isArray(data) || data.length === 0) {
951
+ return (
952
+ <div className="flex h-64 items-center justify-center">
953
+ <p className="text-gray-500">No benchmark data available</p>
954
+ </div>
955
+ );
956
+ }
957
+
958
+ return (
959
+ <div className="grid grid-cols-1 gap-2">
960
+ <div className="grid grid-cols-1 md:grid-cols-12 gap-2">
961
+ {/* Left column: Filters */}
962
+ <div className="md:col-span-3">
963
+ <Card className="shadow-sm h-full">
964
+ <CardHeader className="py-2 px-3">
965
+ <CardTitle className="text-sm">Filters</CardTitle>
966
+ </CardHeader>
967
+ <CardContent className="p-2">
968
+ <div className="space-y-2">
969
+ {/* CPU filter */}
970
+ <div className="space-y-1">
971
+ <Label htmlFor="cpu-filter" className="text-xs">
972
+ CPU
973
+ </Label>
974
+ <Select
975
+ value={filters.cpu}
976
+ onValueChange={(value) => handleFilterChange("cpu", value)}
977
+ >
978
+ <SelectTrigger id="cpu-filter" className="h-7 text-xs">
979
+ <SelectValue placeholder="Select CPU" />
980
+ </SelectTrigger>
981
+ <SelectContent>
982
+ {filterOptions.cpus.map((cpu) => (
983
+ <SelectItem key={cpu} value={cpu} className="text-xs">
984
+ {cpu}
985
+ </SelectItem>
986
+ ))}
987
+ </SelectContent>
988
+ </Select>
989
+ </div>
990
+
991
+ {/* Test case filter with groups */}
992
+ <div className="space-y-1">
993
+ <Label htmlFor="test-case-filter" className="text-xs">
994
+ Test Case
995
+ </Label>
996
+ <Select
997
+ value={filters.testCase}
998
+ onValueChange={(value) =>
999
+ handleFilterChange("testCase", value)
1000
+ }
1001
+ >
1002
+ <SelectTrigger
1003
+ id="test-case-filter"
1004
+ className="h-7 text-xs"
1005
+ >
1006
+ <SelectValue placeholder="Select test" />
1007
+ </SelectTrigger>
1008
+ <SelectContent>
1009
+ {groupedTestCases.sortedGroups.map((group) => (
1010
+ <SelectGroup key={group}>
1011
+ <SelectLabel className="text-xs font-medium text-muted-foreground pl-2">
1012
+ {group}
1013
+ </SelectLabel>
1014
+ {groupedTestCases.groupedTests[group].map(
1015
+ (testCase) => (
1016
+ <SelectItem
1017
+ key={testCase}
1018
+ value={testCase}
1019
+ className="text-xs pl-6"
1020
+ >
1021
+ {testCase}
1022
+ </SelectItem>
1023
+ ),
1024
+ )}
1025
+ </SelectGroup>
1026
+ ))}
1027
+ </SelectContent>
1028
+ </Select>
1029
+ </div>
1030
+
1031
+ <div className="grid grid-cols-2 gap-2">
1032
+ {/* Threads filter - disabled if xAxis is threads */}
1033
+ <div className="space-y-1">
1034
+ <Label htmlFor="threads-filter" className="text-xs">
1035
+ Threads
1036
+ </Label>
1037
+ <Select
1038
+ value={filters.threads.toString()}
1039
+ onValueChange={(value) =>
1040
+ handleFilterChange("threads", Number.parseInt(value))
1041
+ }
1042
+ disabled={filters.xAxis === "threads"}
1043
+ >
1044
+ <SelectTrigger
1045
+ id="threads-filter"
1046
+ className="h-7 text-xs"
1047
+ >
1048
+ <SelectValue
1049
+ placeholder={
1050
+ filters.xAxis === "threads"
1051
+ ? "On X-Axis"
1052
+ : "Select threads"
1053
+ }
1054
+ />
1055
+ </SelectTrigger>
1056
+ <SelectContent>
1057
+ {filterOptions.threads.length > 0 ? (
1058
+ filterOptions.threads.map((thread) => (
1059
+ <SelectItem
1060
+ key={thread}
1061
+ value={thread.toString()}
1062
+ className="text-xs"
1063
+ >
1064
+ {thread}
1065
+ </SelectItem>
1066
+ ))
1067
+ ) : (
1068
+ <SelectItem value="1" className="text-xs">
1069
+ 1
1070
+ </SelectItem>
1071
+ )}
1072
+ </SelectContent>
1073
+ </Select>
1074
+ </div>
1075
+
1076
+ {/* Workers filter - disabled if xAxis is workers */}
1077
+ <div className="space-y-1">
1078
+ <Label htmlFor="workers-filter" className="text-xs">
1079
+ Workers
1080
+ </Label>
1081
+ <Select
1082
+ value={filters.workers.toString()}
1083
+ onValueChange={(value) =>
1084
+ handleFilterChange("workers", Number.parseInt(value))
1085
+ }
1086
+ disabled={filters.xAxis === "workers"}
1087
+ >
1088
+ <SelectTrigger
1089
+ id="workers-filter"
1090
+ className="h-7 text-xs"
1091
+ >
1092
+ <SelectValue
1093
+ placeholder={
1094
+ filters.xAxis === "workers"
1095
+ ? "On X-Axis"
1096
+ : "Select workers"
1097
+ }
1098
+ />
1099
+ </SelectTrigger>
1100
+ <SelectContent>
1101
+ {filterOptions.workers.length > 0 ? (
1102
+ filterOptions.workers.map((worker) => (
1103
+ <SelectItem
1104
+ key={worker}
1105
+ value={worker.toString()}
1106
+ className="text-xs"
1107
+ >
1108
+ {worker}
1109
+ </SelectItem>
1110
+ ))
1111
+ ) : (
1112
+ <SelectItem value="1" className="text-xs">
1113
+ 1
1114
+ </SelectItem>
1115
+ )}
1116
+ </SelectContent>
1117
+ </Select>
1118
+ </div>
1119
+ </div>
1120
+
1121
+ <div className="grid grid-cols-2 gap-2">
1122
+ {/* Concurrency filter - disabled if xAxis is concurrency */}
1123
+ <div className="space-y-1">
1124
+ <Label htmlFor="concurrency-filter" className="text-xs">
1125
+ Concurrency
1126
+ </Label>
1127
+ <Select
1128
+ value={filters.concurrency.toString()}
1129
+ onValueChange={(value) =>
1130
+ handleFilterChange(
1131
+ "concurrency",
1132
+ Number.parseInt(value),
1133
+ )
1134
+ }
1135
+ disabled={filters.xAxis === "concurrency"}
1136
+ >
1137
+ <SelectTrigger
1138
+ id="concurrency-filter"
1139
+ className="h-7 text-xs"
1140
+ >
1141
+ <SelectValue
1142
+ placeholder={
1143
+ filters.xAxis === "concurrency"
1144
+ ? "On X-Axis"
1145
+ : "Select concurrency"
1146
+ }
1147
+ />
1148
+ </SelectTrigger>
1149
+ <SelectContent>
1150
+ {filterOptions.concurrencyLevels.length > 0 ? (
1151
+ filterOptions.concurrencyLevels.map((level) => (
1152
+ <SelectItem
1153
+ key={level}
1154
+ value={level.toString()}
1155
+ className="text-xs"
1156
+ >
1157
+ {level}
1158
+ </SelectItem>
1159
+ ))
1160
+ ) : (
1161
+ <SelectItem value="10" className="text-xs">
1162
+ 10
1163
+ </SelectItem>
1164
+ )}
1165
+ </SelectContent>
1166
+ </Select>
1167
+ </div>
1168
+
1169
+ {/* HTTP2 filter */}
1170
+ <div className="space-y-1">
1171
+ <Label htmlFor="http2-filter" className="text-xs">
1172
+ Protocol
1173
+ </Label>
1174
+ <Select
1175
+ value={filters.http2.toString()}
1176
+ onValueChange={(value) => {
1177
+ if (value === "all") {
1178
+ handleFilterChange("http2", "all");
1179
+ } else {
1180
+ handleFilterChange("http2", value === "true");
1181
+ }
1182
+ }}
1183
+ >
1184
+ <SelectTrigger id="http2-filter" className="h-7 text-xs">
1185
+ <SelectValue placeholder="Select protocol" />
1186
+ </SelectTrigger>
1187
+ <SelectContent>
1188
+ <SelectItem value="all" className="text-xs">
1189
+ All
1190
+ </SelectItem>
1191
+ {filterOptions.http2Options
1192
+ .filter((option) => option !== "all")
1193
+ .map((option) => (
1194
+ <SelectItem
1195
+ key={String(option)}
1196
+ value={String(option)}
1197
+ className="text-xs"
1198
+ >
1199
+ {option ? "HTTP/2" : "HTTP/1.1"}
1200
+ </SelectItem>
1201
+ ))}
1202
+ </SelectContent>
1203
+ </Select>
1204
+ </div>
1205
+ </div>
1206
+
1207
+ {/* X-Axis selection */}
1208
+ <div className="space-y-1">
1209
+ <Label htmlFor="x-axis-select" className="text-xs">
1210
+ X-Axis
1211
+ </Label>
1212
+ <Select
1213
+ value={filters.xAxis}
1214
+ onValueChange={(value) =>
1215
+ handleFilterChange("xAxis", value)
1216
+ }
1217
+ >
1218
+ <SelectTrigger id="x-axis-select" className="h-7 text-xs">
1219
+ <SelectValue placeholder="X-Axis" />
1220
+ </SelectTrigger>
1221
+ <SelectContent>
1222
+ <SelectItem value="concurrency" className="text-xs">
1223
+ Concurrency
1224
+ </SelectItem>
1225
+ <SelectItem value="threads" className="text-xs">
1226
+ Threads
1227
+ </SelectItem>
1228
+ <SelectItem value="workers" className="text-xs">
1229
+ Workers
1230
+ </SelectItem>
1231
+ </SelectContent>
1232
+ </Select>
1233
+ </div>
1234
+
1235
+ {/* Metric selection */}
1236
+ <div className="space-y-1">
1237
+ <Label htmlFor="metric-select" className="text-xs">
1238
+ Metric
1239
+ </Label>
1240
+ <Tabs
1241
+ value={filters.metric}
1242
+ onValueChange={(value) =>
1243
+ handleFilterChange("metric", value)
1244
+ }
1245
+ className="w-full"
1246
+ >
1247
+ <TabsList className="h-6 w-full">
1248
+ <TabsTrigger
1249
+ value="rps"
1250
+ className="text-xs px-2 py-0 h-4 flex-1"
1251
+ >
1252
+ RPS
1253
+ </TabsTrigger>
1254
+ <TabsTrigger
1255
+ value="p95_latency"
1256
+ className="text-xs px-2 py-0 h-4 flex-1"
1257
+ >
1258
+ P95
1259
+ </TabsTrigger>
1260
+ <TabsTrigger
1261
+ value="errorRate"
1262
+ className="text-xs px-2 py-0 h-4 flex-1"
1263
+ >
1264
+ Errors
1265
+ </TabsTrigger>
1266
+ </TabsList>
1267
+ </Tabs>
1268
+
1269
+ {/* Better indicator */}
1270
+ <div className="flex items-center justify-center text-xs text-muted-foreground">
1271
+ <metricInfo.icon className="h-3 w-3 mr-1" />
1272
+ <span>{metricInfo.isBetter} is better</span>
1273
+ </div>
1274
+ </div>
1275
+
1276
+ {/* Compact Summary Stats */}
1277
+ {summaryStats && (
1278
+ <div className="pt-1 space-y-1 text-xs">
1279
+ <div className="flex justify-between">
1280
+ <span className="text-muted-foreground">Results:</span>
1281
+ <span className="font-medium">{summaryStats.count}</span>
1282
+ </div>
1283
+ <div className="flex justify-between">
1284
+ <span className="text-muted-foreground">Min:</span>
1285
+ <span className="font-medium">
1286
+ {metricInfo.formatter(summaryStats.min)}
1287
+ </span>
1288
+ </div>
1289
+ <div className="flex justify-between">
1290
+ <span className="text-muted-foreground">Max:</span>
1291
+ <span className="font-medium">
1292
+ {metricInfo.formatter(summaryStats.max)}
1293
+ </span>
1294
+ </div>
1295
+ <div className="flex justify-between">
1296
+ <span className="text-muted-foreground">Avg:</span>
1297
+ <span className="font-medium">
1298
+ {metricInfo.formatter(summaryStats.avg)}
1299
+ </span>
1300
+ </div>
1301
+ </div>
1302
+ )}
1303
+ </div>
1304
+ </CardContent>
1305
+ </Card>
1306
+ </div>
1307
+
1308
+ {/* Right column: Chart and Error Distribution */}
1309
+ <div className="md:col-span-9">
1310
+ <div className="space-y-2">
1311
+ {/* Main Chart */}
1312
+ <Card className="shadow">
1313
+ <CardHeader className="pb-0 pt-2 px-3">
1314
+ <CardTitle className="text-base">
1315
+ {metricInfo.label}: {filters.cpu} - {filters.testCase}
1316
+ <span className="text-xs font-normal ml-2 text-muted-foreground">
1317
+ {filters.xAxis !== "threads" &&
1318
+ `Threads: ${filters.threads}, `}
1319
+ {filters.xAxis !== "workers" &&
1320
+ `Workers: ${filters.workers}, `}
1321
+ {filters.xAxis !== "concurrency" &&
1322
+ `Concurrency: ${filters.concurrency}, `}
1323
+ Protocol: {getProtocolDisplay()}
1324
+ </span>
1325
+ </CardTitle>
1326
+ </CardHeader>
1327
+ <CardContent className="p-2">
1328
+ {chartData.length === 0 ? (
1329
+ <div className="flex h-64 items-center justify-center">
1330
+ <p className="text-gray-500">
1331
+ No data available for the selected filters
1332
+ </p>
1333
+ </div>
1334
+ ) : (
1335
+ <>
1336
+ <div className="h-[280px]">
1337
+ <ResponsiveContainer width="100%" height="100%">
1338
+ <BarChart
1339
+ data={chartData}
1340
+ margin={{ top: 10, right: 30, left: 30, bottom: 20 }}
1341
+ barCategoryGap={0}
1342
+ barGap={0}
1343
+ >
1344
+ <CartesianGrid strokeDasharray="3 3" opacity={0.7} />
1345
+ <XAxis
1346
+ dataKey={filters.xAxis}
1347
+ label={{
1348
+ value:
1349
+ filters.xAxis.charAt(0).toUpperCase() +
1350
+ filters.xAxis.slice(1),
1351
+ position: "insideBottom",
1352
+ offset: -5,
1353
+ }}
1354
+ />
1355
+
1356
+ <YAxis
1357
+ label={{
1358
+ value: metricInfo.label,
1359
+ angle: -90,
1360
+ position: "insideLeft",
1361
+ offset: -15,
1362
+ style: { textAnchor: "middle" },
1363
+ }}
1364
+ tickFormatter={metricInfo.formatter}
1365
+ />
1366
+
1367
+ {/* Render bars for each visible data key */}
1368
+ {visibleDataKeys.map((dataKey, index) => (
1369
+ <Bar
1370
+ key={dataKey}
1371
+ dataKey={dataKey}
1372
+ name={dataKey}
1373
+ fill={
1374
+ serverColors[
1375
+ dataKey.split(" ")[0].replace(/\+/g, "__")
1376
+ ] ||
1377
+ serverColors[
1378
+ Object.keys(serverColors)[
1379
+ index % Object.keys(serverColors).length
1380
+ ]
1381
+ ]
1382
+ }
1383
+ opacity={activeDataKey === dataKey ? 1 : 0.8}
1384
+ stackId={index}
1385
+ isAnimationActive={true}
1386
+ onMouseOver={(data) =>
1387
+ handleChartHover({
1388
+ activePayload: [
1389
+ { dataKey, payload: data.payload },
1390
+ ],
1391
+ })
1392
+ }
1393
+ onMouseLeave={() => {
1394
+ setHoveredPoint(null);
1395
+ setActiveDataKey(null);
1396
+ }}
1397
+ />
1398
+ ))}
1399
+
1400
+ {/* Add invisible bars for zero values to enable hovering */}
1401
+ {visibleDataKeys.map((dataKey, index) => (
1402
+ <Bar
1403
+ key={`${dataKey}_hover`}
1404
+ dataKey={dataKey}
1405
+ name={`${dataKey}_hover`}
1406
+ fill="transparent"
1407
+ stroke="transparent"
1408
+ strokeWidth={0}
1409
+ minPointSize={20}
1410
+ stackId={`hover_${index}`}
1411
+ isAnimationActive={true}
1412
+ onMouseOver={(data) => {
1413
+ if (data.payload[dataKey] === 0) {
1414
+ handleChartHover({
1415
+ activePayload: [
1416
+ { dataKey, payload: data.payload },
1417
+ ],
1418
+ });
1419
+ }
1420
+ }}
1421
+ onMouseLeave={() => {
1422
+ setHoveredPoint(null);
1423
+ setActiveDataKey(null);
1424
+ }}
1425
+ />
1426
+ ))}
1427
+ </BarChart>
1428
+ </ResponsiveContainer>
1429
+ </div>
1430
+
1431
+ {/* Server Toggle Legend */}
1432
+ <div className="flex flex-wrap gap-3 justify-center mt-1">
1433
+ {filterOptions.servers.map((server) => (
1434
+ <div
1435
+ key={server}
1436
+ className={`flex items-center gap-1 px-2 py-0.5 rounded cursor-pointer transition-opacity ${
1437
+ visibleServers[server]
1438
+ ? "opacity-100"
1439
+ : "opacity-40"
1440
+ }`}
1441
+ onClick={(event) => handleLegendClick(server, event)}
1442
+ title={`Click to toggle, ${navigator.platform.includes("Mac") ? "Cmd" : "Ctrl"}+click to show only this server`}
1443
+ >
1444
+ <div
1445
+ className="w-2 h-2 rounded-sm"
1446
+ style={{ backgroundColor: serverColors[server] }}
1447
+ />
1448
+ <span className="text-xs">
1449
+ {formatServerName(server)}
1450
+ </span>
1451
+ </div>
1452
+ ))}
1453
+ </div>
1454
+ </>
1455
+ )}
1456
+ </CardContent>
1457
+ </Card>
1458
+
1459
+ {/* Top Performers and Benchmark Details in horizontal layout */}
1460
+ <div className="grid grid-cols-1 lg:grid-cols-12 gap-2">
1461
+ <div
1462
+ className={
1463
+ topPerformers.length > 0 ? "lg:col-span-7" : "lg:col-span-12"
1464
+ }
1465
+ >
1466
+ <Card className="shadow-sm h-full">
1467
+ <CardHeader className="py-1 px-3">
1468
+ <CardTitle className="text-sm flex items-center">
1469
+ {hoveredPoint ? "Benchmark Details" : "Hover Details"}
1470
+ {!hoveredPoint && (
1471
+ <span className="ml-2 text-xs font-normal text-muted-foreground flex items-center">
1472
+ <InfoIcon className="h-3 w-3 mr-1" />
1473
+ Hover over chart bars to see details
1474
+ </span>
1475
+ )}
1476
+ </CardTitle>
1477
+ </CardHeader>
1478
+ <CardContent className="p-2">
1479
+ {hoveredPoint ? (
1480
+ <div className="space-y-2">
1481
+ {/* Header with server and test case */}
1482
+ <div className="flex flex-wrap items-center gap-2">
1483
+ <Badge
1484
+ variant="outline"
1485
+ className="flex items-center"
1486
+ >
1487
+ <div
1488
+ className="w-2 h-2 mr-1 rounded-full"
1489
+ style={{
1490
+ backgroundColor:
1491
+ serverColors[hoveredPoint.server],
1492
+ }}
1493
+ />
1494
+ {formatServerName(hoveredPoint.server)}
1495
+ </Badge>
1496
+ <Badge variant="outline">
1497
+ {hoveredPoint.test_case}
1498
+ </Badge>
1499
+ <Badge variant="outline">
1500
+ {filters.xAxis}:{" "}
1501
+ {String(
1502
+ hoveredPoint[
1503
+ filters.xAxis as keyof BenchmarkResult
1504
+ ],
1505
+ )}
1506
+ </Badge>
1507
+ <Badge variant="outline">
1508
+ {hoveredPoint.http2 ? "HTTP/2" : "HTTP/1.1"}
1509
+ </Badge>
1510
+ </div>
1511
+
1512
+ {/* Version information in a separate row */}
1513
+ {hoveredPoint.version && (
1514
+ <div className="flex items-center text-xs text-muted-foreground">
1515
+ <TagIcon className="h-3 w-3 mr-1" />
1516
+ <span className="font-medium overflow-hidden text-ellipsis">
1517
+ {hoveredPoint.version}
1518
+ </span>
1519
+ </div>
1520
+ )}
1521
+
1522
+ {/* Performance metrics */}
1523
+ <div className="grid grid-cols-2 md:grid-cols-2 gap-x-2 gap-y-1 text-xs">
1524
+ <div className="flex justify-between">
1525
+ <span className="text-muted-foreground">RPS:</span>
1526
+ <span className="font-medium">
1527
+ {hoveredPoint.results.requestsPerSec.toFixed(2)}
1528
+ </span>
1529
+ </div>
1530
+ <div className="flex justify-between">
1531
+ <span className="text-muted-foreground">
1532
+ Success Rate:
1533
+ </span>
1534
+ <span
1535
+ className={`font-medium ${hoveredPoint.results.successRate < 1 ? "text-red-600" : ""}`}
1536
+ >
1537
+ {(hoveredPoint.results.successRate * 100).toFixed(
1538
+ 2,
1539
+ )}
1540
+ %
1541
+ </span>
1542
+ </div>
1543
+ <br />
1544
+ </div>
1545
+ <div className="grid grid-cols-2 md:grid-cols-2 gap-x-2 gap-y-1 text-xs">
1546
+ <div className="flex justify-between">
1547
+ <span className="text-muted-foreground">
1548
+ P95 Latency:
1549
+ </span>
1550
+ <span className="font-medium">
1551
+ {hoveredPoint.results.p95_latency != null
1552
+ ? `${hoveredPoint.results.p95_latency.toFixed(2)} ms`
1553
+ : "N/A"}
1554
+ </span>
1555
+ </div>
1556
+ <div className="flex justify-between">
1557
+ <span className="text-muted-foreground">
1558
+ Avg Latency:
1559
+ </span>
1560
+ <span className="font-medium">
1561
+ {hoveredPoint.results.average != null
1562
+ ? `${hoveredPoint.results.average.toFixed(2)} ms`
1563
+ : "N/A"}
1564
+ </span>
1565
+ </div>
1566
+ </div>
1567
+
1568
+ {/* Error distribution */}
1569
+ {Object.keys(
1570
+ hoveredPoint.results.errorDistribution || {},
1571
+ ).length > 0 ? (
1572
+ <div>
1573
+ <div className="text-xs font-medium mb-1">
1574
+ Error Distribution:
1575
+ </div>
1576
+ <div className="space-y-1">
1577
+ {Object.entries(
1578
+ hoveredPoint.results.errorDistribution || {},
1579
+ ).map(([errorType, count]) => (
1580
+ <div
1581
+ key={errorType}
1582
+ className="flex items-center justify-between text-xs"
1583
+ >
1584
+ <span>{errorType}</span>
1585
+ <Badge
1586
+ variant="destructive"
1587
+ className="text-xs py-0"
1588
+ >
1589
+ {count}
1590
+ </Badge>
1591
+ </div>
1592
+ ))}
1593
+ </div>
1594
+ </div>
1595
+ ) : (
1596
+ <p className="text-xs text-muted-foreground">
1597
+ No errors reported
1598
+ </p>
1599
+ )}
1600
+ </div>
1601
+ ) : (
1602
+ <p className="text-xs text-muted-foreground">
1603
+ Hover over a data point to see benchmark details
1604
+ </p>
1605
+ )}
1606
+ </CardContent>
1607
+ </Card>
1608
+ </div>
1609
+ {topPerformers.length > 0 && (
1610
+ <div className="lg:col-span-5">
1611
+ <Card className="shadow-sm h-full">
1612
+ <CardHeader className="py-1 px-3">
1613
+ <CardTitle className="text-sm flex items-center">
1614
+ Top Performers{" "}
1615
+ {filteredData[0] && (filteredData[0] as any).group && (
1616
+ <span className="ml-1 text-xs text-muted-foreground">
1617
+ ({(filteredData[0] as any).group})
1618
+ </span>
1619
+ )}
1620
+ </CardTitle>
1621
+ </CardHeader>
1622
+ <CardContent className="p-2">
1623
+ <div className="grid grid-cols-2 gap-x-3 gap-y-1">
1624
+ {topPerformers.map(({ server, count }) => (
1625
+ <div
1626
+ key={server}
1627
+ className="flex items-center justify-between text-xs"
1628
+ >
1629
+ <div className="flex items-center gap-1">
1630
+ <div
1631
+ className="w-2 h-2 rounded-full"
1632
+ style={{
1633
+ backgroundColor: serverColors[server],
1634
+ }}
1635
+ />
1636
+ <span className="truncate">
1637
+ {formatServerName(server)}
1638
+ </span>
1639
+ </div>
1640
+ <span className="text-muted-foreground ml-1">
1641
+ {count}
1642
+ </span>
1643
+ </div>
1644
+ ))}
1645
+ </div>
1646
+ {(filteredData[0] as any).group == "rack" && (
1647
+ <p className="text-[10px] text-muted-foreground mt-2 px-1 leading-snug">
1648
+ Note: Some servers (e.g. Unicorn, Agoo) don’t
1649
+ participate in multi-threaded test cases so will
1650
+ appear less frequently in these results.
1651
+ </p>
1652
+ )}
1653
+ </CardContent>
1654
+ </Card>
1655
+ </div>
1656
+ )}
1657
+ </div>
1658
+ </div>
1659
+ </div>
1660
+ </div>
1661
+ </div>
1662
+ );
1663
+ }