nitrostack 1.0.65 → 1.0.67

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 (61) hide show
  1. package/package.json +3 -2
  2. package/src/studio/README.md +140 -0
  3. package/src/studio/app/api/auth/fetch-metadata/route.ts +71 -0
  4. package/src/studio/app/api/auth/register-client/route.ts +67 -0
  5. package/src/studio/app/api/chat/route.ts +250 -0
  6. package/src/studio/app/api/health/checks/route.ts +42 -0
  7. package/src/studio/app/api/health/route.ts +13 -0
  8. package/src/studio/app/api/init/route.ts +109 -0
  9. package/src/studio/app/api/ping/route.ts +13 -0
  10. package/src/studio/app/api/prompts/[name]/route.ts +21 -0
  11. package/src/studio/app/api/prompts/route.ts +13 -0
  12. package/src/studio/app/api/resources/[...uri]/route.ts +18 -0
  13. package/src/studio/app/api/resources/route.ts +13 -0
  14. package/src/studio/app/api/roots/route.ts +13 -0
  15. package/src/studio/app/api/sampling/route.ts +14 -0
  16. package/src/studio/app/api/tools/[name]/call/route.ts +41 -0
  17. package/src/studio/app/api/tools/route.ts +23 -0
  18. package/src/studio/app/api/widget-examples/route.ts +44 -0
  19. package/src/studio/app/auth/callback/page.tsx +175 -0
  20. package/src/studio/app/auth/page.tsx +560 -0
  21. package/src/studio/app/chat/page.tsx +1133 -0
  22. package/src/studio/app/chat/page.tsx.backup +390 -0
  23. package/src/studio/app/globals.css +486 -0
  24. package/src/studio/app/health/page.tsx +179 -0
  25. package/src/studio/app/layout.tsx +68 -0
  26. package/src/studio/app/logs/page.tsx +279 -0
  27. package/src/studio/app/page.tsx +351 -0
  28. package/src/studio/app/page.tsx.backup +346 -0
  29. package/src/studio/app/ping/page.tsx +209 -0
  30. package/src/studio/app/prompts/page.tsx +230 -0
  31. package/src/studio/app/resources/page.tsx +315 -0
  32. package/src/studio/app/settings/page.tsx +199 -0
  33. package/src/studio/branding.md +807 -0
  34. package/src/studio/components/EnlargeModal.tsx +138 -0
  35. package/src/studio/components/LogMessage.tsx +153 -0
  36. package/src/studio/components/MarkdownRenderer.tsx +410 -0
  37. package/src/studio/components/Sidebar.tsx +295 -0
  38. package/src/studio/components/ToolCard.tsx +139 -0
  39. package/src/studio/components/WidgetRenderer.tsx +346 -0
  40. package/src/studio/lib/api.ts +207 -0
  41. package/src/studio/lib/http-client-transport.ts +222 -0
  42. package/src/studio/lib/llm-service.ts +480 -0
  43. package/src/studio/lib/log-manager.ts +76 -0
  44. package/src/studio/lib/mcp-client.ts +258 -0
  45. package/src/studio/lib/store.ts +192 -0
  46. package/src/studio/lib/theme-provider.tsx +50 -0
  47. package/src/studio/lib/types.ts +107 -0
  48. package/src/studio/lib/widget-loader.ts +90 -0
  49. package/src/studio/middleware.ts +27 -0
  50. package/src/studio/next.config.js +38 -0
  51. package/src/studio/package.json +35 -0
  52. package/src/studio/postcss.config.mjs +10 -0
  53. package/src/studio/public/nitrocloud.png +0 -0
  54. package/src/studio/tailwind.config.ts +67 -0
  55. package/src/studio/tsconfig.json +42 -0
  56. package/templates/typescript-oauth/AI_AGENT_CLI_REFERENCE.md +0 -701
  57. package/templates/typescript-oauth/AI_AGENT_SDK_REFERENCE.md +0 -1260
  58. package/templates/typescript-oauth/package-lock.json +0 -4253
  59. package/templates/typescript-pizzaz/IMPLEMENTATION.md +0 -98
  60. package/templates/typescript-starter/AI_AGENT_CLI_REFERENCE.md +0 -701
  61. package/templates/typescript-starter/AI_AGENT_SDK_REFERENCE.md +0 -1260
@@ -0,0 +1,346 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+ import { useStudioStore } from '@/lib/store';
5
+ import { api } from '@/lib/api';
6
+ import { ToolCard } from '@/components/ToolCard';
7
+ import { EnlargeModal } from '@/components/EnlargeModal';
8
+ import { WidgetRenderer } from '@/components/WidgetRenderer';
9
+ import type { Tool } from '@/lib/types';
10
+
11
+ export default function ToolsPage() {
12
+ const { tools, setTools, loading, setLoading, connection, setConnection } = useStudioStore();
13
+ const [searchQuery, setSearchQuery] = useState('');
14
+ const [selectedTool, setSelectedTool] = useState<Tool | null>(null);
15
+ const [toolArgs, setToolArgs] = useState<Record<string, any>>({});
16
+ const [toolResult, setToolResult] = useState<any>(null);
17
+ const [executingTool, setExecutingTool] = useState(false);
18
+
19
+ // Initialize MCP, load tools and check connection on mount
20
+ useEffect(() => {
21
+ const init = async () => {
22
+ await api.initialize();
23
+ await loadTools();
24
+ await checkConnection();
25
+ };
26
+ init();
27
+ }, []);
28
+
29
+ const checkConnection = async () => {
30
+ try {
31
+ const health = await api.checkConnection();
32
+ setConnection({
33
+ connected: health.connected,
34
+ status: health.connected ? 'connected' : 'disconnected',
35
+ });
36
+ } catch (error) {
37
+ setConnection({ connected: false, status: 'disconnected' });
38
+ }
39
+ };
40
+
41
+ const loadTools = async () => {
42
+ setLoading('tools', true);
43
+ try {
44
+ const data = await api.getTools();
45
+ setTools(data.tools || []);
46
+ } catch (error) {
47
+ console.error('Failed to load tools:', error);
48
+ } finally {
49
+ setLoading('tools', false);
50
+ }
51
+ };
52
+
53
+ const handleExecuteTool = (tool: Tool) => {
54
+ setSelectedTool(tool);
55
+ setToolArgs({});
56
+ setToolResult(null);
57
+ };
58
+
59
+ const handleSubmitTool = async (e: React.FormEvent) => {
60
+ e.preventDefault();
61
+ if (!selectedTool) return;
62
+
63
+ setExecutingTool(true);
64
+ setToolResult(null);
65
+
66
+ try {
67
+ const result = await api.callTool(selectedTool.name, toolArgs);
68
+ setToolResult(result);
69
+
70
+ // Check for JWT token in login response
71
+ if (selectedTool.name === 'login' && result.content) {
72
+ try {
73
+ const content = result.content[0]?.text;
74
+ if (content) {
75
+ const parsed = JSON.parse(content);
76
+ if (parsed.token) {
77
+ useStudioStore.getState().setJwtToken(parsed.token);
78
+ }
79
+ }
80
+ } catch (e) {
81
+ // Ignore parsing errors
82
+ }
83
+ }
84
+ } catch (error) {
85
+ console.error('Tool execution failed:', error);
86
+ setToolResult({ error: 'Tool execution failed' });
87
+ } finally {
88
+ setExecutingTool(false);
89
+ }
90
+ };
91
+
92
+ const filteredTools = tools.filter((tool) =>
93
+ tool.name.toLowerCase().includes(searchQuery.toLowerCase())
94
+ );
95
+
96
+ return (
97
+ <>
98
+ <div className="min-h-screen bg-dark-bg p-8">
99
+ {/* Header */}
100
+ <div className="mb-8">
101
+ <div className="flex items-center justify-between mb-4">
102
+ <div>
103
+ <h1 className="text-3xl font-bold">⚡ Tools</h1>
104
+ <p className="text-text-secondary mt-1">
105
+ Browse and execute MCP tools
106
+ </p>
107
+ </div>
108
+ <button onClick={loadTools} className="btn btn-primary">
109
+ <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
110
+ <path
111
+ strokeLinecap="round"
112
+ strokeLinejoin="round"
113
+ strokeWidth={2}
114
+ d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
115
+ />
116
+ </svg>
117
+ Refresh
118
+ </button>
119
+ </div>
120
+
121
+ {/* Search */}
122
+ <input
123
+ type="text"
124
+ placeholder="Search tools..."
125
+ value={searchQuery}
126
+ onChange={(e) => setSearchQuery(e.target.value)}
127
+ className="input"
128
+ />
129
+ </div>
130
+
131
+ {/* Tools Grid */}
132
+ {loading.tools ? (
133
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
134
+ {[1, 2, 3].map((i) => (
135
+ <div key={i} className="card skeleton h-64"></div>
136
+ ))}
137
+ </div>
138
+ ) : filteredTools.length === 0 ? (
139
+ <div className="text-center py-16">
140
+ <p className="text-text-secondary text-lg">
141
+ {searchQuery ? 'No tools found matching your search' : 'No tools available'}
142
+ </p>
143
+ </div>
144
+ ) : (
145
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
146
+ {filteredTools.map((tool) => (
147
+ <ToolCard key={tool.name} tool={tool} onExecute={handleExecuteTool} />
148
+ ))}
149
+ </div>
150
+ )}
151
+
152
+ {/* Tool Executor Modal */}
153
+ {selectedTool && (
154
+ <div
155
+ className="fixed inset-0 z-40 flex items-center justify-center"
156
+ style={{ backgroundColor: 'rgba(0, 0, 0, 0.8)' }}
157
+ onClick={() => setSelectedTool(null)}
158
+ >
159
+ <div
160
+ className="w-[700px] max-h-[90vh] overflow-auto rounded-2xl border p-6"
161
+ style={{
162
+ backgroundColor: '#ffffff',
163
+ borderColor: '#e5e7eb'
164
+ }}
165
+ onClick={(e) => e.stopPropagation()}
166
+ >
167
+ <div className="flex items-center justify-between mb-4">
168
+ <h2 className="text-xl font-bold">{selectedTool.name}</h2>
169
+ <button
170
+ onClick={() => setSelectedTool(null)}
171
+ className="text-text-secondary hover:text-text-primary"
172
+ >
173
+
174
+ </button>
175
+ </div>
176
+
177
+ <p className="text-sm text-text-secondary mb-6">
178
+ {selectedTool.description || 'No description'}
179
+ </p>
180
+
181
+ <form onSubmit={handleSubmitTool}>
182
+ {/* Debug Info */}
183
+ <div className="mb-4 p-4 rounded-lg" style={{ backgroundColor: '#fef3c7', border: '1px solid #fbbf24' }}>
184
+ <p className="text-xs font-mono" style={{ color: '#92400e' }}>
185
+ DEBUG: Has inputSchema: {selectedTool.inputSchema ? 'YES' : 'NO'}<br/>
186
+ Has properties: {selectedTool.inputSchema?.properties ? 'YES' : 'NO'}<br/>
187
+ Properties type: {typeof selectedTool.inputSchema?.properties}<br/>
188
+ Properties keys: {selectedTool.inputSchema?.properties ? JSON.stringify(Object.keys(selectedTool.inputSchema.properties)) : 'NONE'}<br/>
189
+ Raw schema: {JSON.stringify(selectedTool.inputSchema, null, 2).substring(0, 200)}...
190
+ </p>
191
+ </div>
192
+
193
+ {/* Generate form inputs from schema */}
194
+ {selectedTool.inputSchema?.properties && typeof selectedTool.inputSchema.properties === 'object' && Object.keys(selectedTool.inputSchema.properties).length > 0 ? (
195
+ <>
196
+ <div className="mb-4 p-3 rounded-lg" style={{ backgroundColor: '#f0fdf4' }}>
197
+ <p className="text-sm font-medium" style={{ color: '#166534' }}>✓ Input Fields Detected:</p>
198
+ </div>
199
+ {Object.entries(selectedTool.inputSchema.properties).map(([key, prop]: [string, any]) => {
200
+ const isRequired = selectedTool.inputSchema?.required?.includes(key);
201
+
202
+ console.log('Rendering input field:', { key, prop, isRequired });
203
+
204
+ // Handle different input types
205
+ if (prop.enum) {
206
+ // Enum/Select field
207
+ return (
208
+ <div key={key} className="mb-4">
209
+ <label className="block text-sm font-medium mb-2">
210
+ {prop.title || key}
211
+ {isRequired && <span className="text-red-500 ml-1">*</span>}
212
+ </label>
213
+ <select
214
+ className="input"
215
+ value={toolArgs[key] || prop.default || ''}
216
+ onChange={(e) => setToolArgs({ ...toolArgs, [key]: e.target.value })}
217
+ required={isRequired}
218
+ >
219
+ <option value="">Select...</option>
220
+ {prop.enum.map((val: any) => (
221
+ <option key={val} value={val}>
222
+ {val}
223
+ </option>
224
+ ))}
225
+ </select>
226
+ {prop.description && (
227
+ <p className="text-xs text-muted-foreground mt-1">{prop.description}</p>
228
+ )}
229
+ </div>
230
+ );
231
+ } else if (prop.type === 'boolean') {
232
+ // Checkbox field
233
+ return (
234
+ <div key={key} className="mb-4">
235
+ <label className="flex items-center gap-2">
236
+ <input
237
+ type="checkbox"
238
+ checked={toolArgs[key] || false}
239
+ onChange={(e) => setToolArgs({ ...toolArgs, [key]: e.target.checked })}
240
+ />
241
+ <span className="text-sm font-medium">
242
+ {prop.title || key}
243
+ {isRequired && <span className="text-red-500 ml-1">*</span>}
244
+ </span>
245
+ </label>
246
+ {prop.description && (
247
+ <p className="text-xs text-muted-foreground mt-1">{prop.description}</p>
248
+ )}
249
+ </div>
250
+ );
251
+ } else {
252
+ // Text/Number field
253
+ return (
254
+ <div key={key} className="mb-4">
255
+ <label className="block text-sm font-medium mb-2">
256
+ {prop.title || key}
257
+ {isRequired && <span className="text-red-500 ml-1">*</span>}
258
+ </label>
259
+ <input
260
+ type={prop.type === 'number' || prop.type === 'integer' ? 'number' : 'text'}
261
+ className="input"
262
+ value={toolArgs[key] || prop.default || ''}
263
+ onChange={(e) => {
264
+ const value = prop.type === 'number' || prop.type === 'integer'
265
+ ? (e.target.value ? Number(e.target.value) : '')
266
+ : e.target.value;
267
+ setToolArgs({ ...toolArgs, [key]: value });
268
+ }}
269
+ required={isRequired}
270
+ placeholder={prop.description}
271
+ min={prop.minimum}
272
+ max={prop.maximum}
273
+ />
274
+ {prop.description && (
275
+ <p className="text-xs text-muted-foreground mt-1">{prop.description}</p>
276
+ )}
277
+ </div>
278
+ );
279
+ }
280
+ })}
281
+ </>
282
+ ) : (
283
+ <p className="text-sm text-muted-foreground mb-4">No input required</p>
284
+ )}
285
+
286
+ <button
287
+ type="submit"
288
+ className="btn btn-primary w-full"
289
+ disabled={executingTool}
290
+ >
291
+ {executingTool ? 'Executing...' : 'Execute Tool'}
292
+ </button>
293
+ </form>
294
+
295
+ {/* Result */}
296
+ {toolResult && (
297
+ <div className="mt-6 space-y-4">
298
+ <div>
299
+ <h3 className="font-medium mb-2">Result:</h3>
300
+ <pre className="bg-muted p-4 rounded-lg text-sm overflow-auto max-h-64 text-foreground">
301
+ {JSON.stringify(toolResult, null, 2)}
302
+ </pre>
303
+ </div>
304
+
305
+ {/* Widget UI Rendering */}
306
+ {(selectedTool.widget || selectedTool.outputTemplate) && toolResult && (
307
+ <div>
308
+ <h3 className="font-medium mb-2">UI Widget:</h3>
309
+ <div className="border border-border rounded-lg overflow-hidden h-64">
310
+ <WidgetRenderer
311
+ uri={selectedTool.widget?.route || selectedTool.outputTemplate}
312
+ data={(() => {
313
+ // Try to parse JSON from content[0].text, otherwise use raw result
314
+ if (toolResult.content?.[0]?.text) {
315
+ try {
316
+ const parsed = JSON.parse(toolResult.content[0].text);
317
+ // Unwrap if response was wrapped by TransformInterceptor
318
+ // Check if it has the interceptor's structure: { success, data, metadata }
319
+ if (parsed.success !== undefined && parsed.data !== undefined) {
320
+ return parsed.data; // Return the unwrapped data
321
+ }
322
+ return parsed;
323
+ } catch {
324
+ // If parsing fails, return the text wrapped in an object
325
+ return { message: toolResult.content[0].text };
326
+ }
327
+ }
328
+ return toolResult;
329
+ })()}
330
+ className="w-full h-full"
331
+ />
332
+ </div>
333
+ </div>
334
+ )}
335
+ </div>
336
+ )}
337
+ </div>
338
+ </div>
339
+ )}
340
+ </div>
341
+
342
+ <EnlargeModal />
343
+ </>
344
+ );
345
+ }
346
+
@@ -0,0 +1,209 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { useStudioStore } from '@/lib/store';
5
+ import { api } from '@/lib/api';
6
+ import { Wifi, Activity, Clock, TrendingUp, Radio } from 'lucide-react';
7
+
8
+ export default function PingPage() {
9
+ const { pingHistory, addPingResult } = useStudioStore();
10
+ const [pinging, setPinging] = useState(false);
11
+ const [lastLatency, setLastLatency] = useState<number | null>(null);
12
+
13
+ const handlePing = async () => {
14
+ setPinging(true);
15
+ const startTime = Date.now();
16
+
17
+ try {
18
+ await api.ping();
19
+ const latency = Date.now() - startTime;
20
+ setLastLatency(latency);
21
+ addPingResult({ time: new Date(), latency });
22
+ } catch (error) {
23
+ console.error('Ping failed:', error);
24
+ } finally {
25
+ setPinging(false);
26
+ }
27
+ };
28
+
29
+ const averageLatency =
30
+ pingHistory.length > 0
31
+ ? Math.round(pingHistory.reduce((sum, p) => sum + p.latency, 0) / pingHistory.length)
32
+ : null;
33
+
34
+ const minLatency =
35
+ pingHistory.length > 0
36
+ ? Math.min(...pingHistory.map((p) => p.latency))
37
+ : null;
38
+
39
+ const maxLatency =
40
+ pingHistory.length > 0
41
+ ? Math.max(...pingHistory.map((p) => p.latency))
42
+ : null;
43
+
44
+ const getLatencyColor = (latency: number) => {
45
+ if (latency < 100) return 'text-emerald-500';
46
+ if (latency < 500) return 'text-amber-500';
47
+ return 'text-rose-500';
48
+ };
49
+
50
+ const getLatencyBg = (latency: number) => {
51
+ if (latency < 100) return 'bg-emerald-500/10 border-emerald-500/20';
52
+ if (latency < 500) return 'bg-amber-500/10 border-amber-500/20';
53
+ return 'bg-rose-500/10 border-rose-500/20';
54
+ };
55
+
56
+ const getLatencyDotColor = (latency: number) => {
57
+ if (latency < 100) return 'bg-emerald-500';
58
+ if (latency < 500) return 'bg-amber-500';
59
+ return 'bg-rose-500';
60
+ };
61
+
62
+ return (
63
+ <div className="fixed inset-0 flex flex-col bg-background" style={{ left: 'var(--sidebar-width, 15rem)' }}>
64
+ {/* Sticky Header */}
65
+ <div className="sticky top-0 z-10 border-b border-border/50 px-6 py-3 flex items-center justify-between bg-card/80 backdrop-blur-md shadow-sm">
66
+ <div className="flex items-center gap-3">
67
+ <div className="w-8 h-8 rounded-lg bg-gradient-to-br from-cyan-500 to-blue-500 flex items-center justify-center shadow-md">
68
+ <Wifi className="w-5 h-5 text-white" strokeWidth={2.5} />
69
+ </div>
70
+ <div>
71
+ <h1 className="text-lg font-bold text-foreground">Ping</h1>
72
+ </div>
73
+ </div>
74
+ </div>
75
+
76
+ {/* Content - ONLY this scrolls */}
77
+ <div className="flex-1 overflow-y-auto overflow-x-hidden">
78
+ <div className="max-w-4xl mx-auto px-6 py-6">
79
+
80
+ {/* Main Ping Card */}
81
+ <div className="card card-hover p-8 mb-8 text-center bg-gradient-to-br from-card via-card to-muted/20">
82
+ <button
83
+ onClick={handlePing}
84
+ disabled={pinging}
85
+ className="btn btn-primary btn-lg mx-auto px-12 py-4 text-lg gap-3 shadow-lg hover:shadow-xl transition-all"
86
+ >
87
+ {pinging ? (
88
+ <>
89
+ <Radio className="w-6 h-6 animate-pulse" />
90
+ Pinging...
91
+ </>
92
+ ) : (
93
+ <>
94
+ <Wifi className="w-6 h-6" />
95
+ Send Ping
96
+ </>
97
+ )}
98
+ </button>
99
+
100
+ {lastLatency !== null && (
101
+ <div className="mt-8 animate-fade-in">
102
+ <div className={`inline-block px-8 py-4 rounded-2xl border ${getLatencyBg(lastLatency)}`}>
103
+ <div className={`text-6xl font-bold ${getLatencyColor(lastLatency)}`}>
104
+ {lastLatency}ms
105
+ </div>
106
+ <div className="text-muted-foreground mt-2 flex items-center justify-center gap-2">
107
+ <Clock className="w-4 h-4" />
108
+ Last ping latency
109
+ </div>
110
+ </div>
111
+ </div>
112
+ )}
113
+ </div>
114
+
115
+ {/* Statistics Grid */}
116
+ {pingHistory.length > 0 && (
117
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
118
+ {/* Average */}
119
+ <div className="card card-hover p-6">
120
+ <div className="flex items-center gap-3 mb-3">
121
+ <div className="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center">
122
+ <TrendingUp className="w-5 h-5 text-blue-500" />
123
+ </div>
124
+ <h3 className="font-semibold text-foreground">Average</h3>
125
+ </div>
126
+ <div className="text-3xl font-bold text-foreground">{averageLatency}ms</div>
127
+ <p className="text-sm text-muted-foreground mt-1">{pingHistory.length} total pings</p>
128
+ </div>
129
+
130
+ {/* Min */}
131
+ <div className="card card-hover p-6">
132
+ <div className="flex items-center gap-3 mb-3">
133
+ <div className="w-10 h-10 rounded-lg bg-emerald-500/10 flex items-center justify-center">
134
+ <Activity className="w-5 h-5 text-emerald-500" />
135
+ </div>
136
+ <h3 className="font-semibold text-foreground">Fastest</h3>
137
+ </div>
138
+ <div className="text-3xl font-bold text-emerald-500">{minLatency}ms</div>
139
+ <p className="text-sm text-muted-foreground mt-1">Best response time</p>
140
+ </div>
141
+
142
+ {/* Max */}
143
+ <div className="card card-hover p-6">
144
+ <div className="flex items-center gap-3 mb-3">
145
+ <div className="w-10 h-10 rounded-lg bg-amber-500/10 flex items-center justify-center">
146
+ <Clock className="w-5 h-5 text-amber-500" />
147
+ </div>
148
+ <h3 className="font-semibold text-foreground">Slowest</h3>
149
+ </div>
150
+ <div className="text-3xl font-bold text-amber-500">{maxLatency}ms</div>
151
+ <p className="text-sm text-muted-foreground mt-1">Worst response time</p>
152
+ </div>
153
+ </div>
154
+ )}
155
+
156
+ {/* Ping History */}
157
+ {pingHistory.length > 0 && (
158
+ <div>
159
+ <h2 className="text-2xl font-semibold text-foreground mb-6 flex items-center gap-2">
160
+ <Activity className="w-6 h-6 text-primary" />
161
+ Recent Pings
162
+ </h2>
163
+ <div className="space-y-3">
164
+ {pingHistory.slice().reverse().map((ping, idx) => (
165
+ <div
166
+ key={idx}
167
+ className="card card-hover p-5 flex items-center justify-between animate-fade-in"
168
+ >
169
+ <div className="flex items-center gap-3">
170
+ <div className={`w-3 h-3 rounded-full ${getLatencyDotColor(ping.latency)} shadow-lg`} />
171
+ <span className="text-sm text-muted-foreground font-mono">
172
+ {ping.time.toLocaleTimeString()}
173
+ </span>
174
+ </div>
175
+ <div className="flex items-center gap-3">
176
+ <span className={`text-lg font-bold ${getLatencyColor(ping.latency)}`}>
177
+ {ping.latency}ms
178
+ </span>
179
+ <span className={`badge ${
180
+ ping.latency < 100
181
+ ? 'badge-success'
182
+ : ping.latency < 500
183
+ ? 'badge-warning'
184
+ : 'badge-error'
185
+ }`}>
186
+ {ping.latency < 100 ? 'Excellent' : ping.latency < 500 ? 'Good' : 'Slow'}
187
+ </span>
188
+ </div>
189
+ </div>
190
+ ))}
191
+ </div>
192
+ </div>
193
+ )}
194
+
195
+ {/* Empty State */}
196
+ {pingHistory.length === 0 && !pinging && (
197
+ <div className="empty-state">
198
+ <Wifi className="empty-state-icon" />
199
+ <p className="empty-state-title">No ping history yet</p>
200
+ <p className="empty-state-description">
201
+ Click the "Send Ping" button above to test your connection
202
+ </p>
203
+ </div>
204
+ )}
205
+ </div>
206
+ </div>
207
+ </div>
208
+ );
209
+ }