nitrostack 1.0.65 → 1.0.66
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/package.json +2 -1
- package/src/studio/README.md +140 -0
- package/src/studio/app/api/auth/fetch-metadata/route.ts +71 -0
- package/src/studio/app/api/auth/register-client/route.ts +67 -0
- package/src/studio/app/api/chat/route.ts +250 -0
- package/src/studio/app/api/health/checks/route.ts +42 -0
- package/src/studio/app/api/health/route.ts +13 -0
- package/src/studio/app/api/init/route.ts +109 -0
- package/src/studio/app/api/ping/route.ts +13 -0
- package/src/studio/app/api/prompts/[name]/route.ts +21 -0
- package/src/studio/app/api/prompts/route.ts +13 -0
- package/src/studio/app/api/resources/[...uri]/route.ts +18 -0
- package/src/studio/app/api/resources/route.ts +13 -0
- package/src/studio/app/api/roots/route.ts +13 -0
- package/src/studio/app/api/sampling/route.ts +14 -0
- package/src/studio/app/api/tools/[name]/call/route.ts +41 -0
- package/src/studio/app/api/tools/route.ts +23 -0
- package/src/studio/app/api/widget-examples/route.ts +44 -0
- package/src/studio/app/auth/callback/page.tsx +175 -0
- package/src/studio/app/auth/page.tsx +560 -0
- package/src/studio/app/chat/page.tsx +1133 -0
- package/src/studio/app/chat/page.tsx.backup +390 -0
- package/src/studio/app/globals.css +486 -0
- package/src/studio/app/health/page.tsx +179 -0
- package/src/studio/app/layout.tsx +68 -0
- package/src/studio/app/logs/page.tsx +279 -0
- package/src/studio/app/page.tsx +351 -0
- package/src/studio/app/page.tsx.backup +346 -0
- package/src/studio/app/ping/page.tsx +209 -0
- package/src/studio/app/prompts/page.tsx +230 -0
- package/src/studio/app/resources/page.tsx +315 -0
- package/src/studio/app/settings/page.tsx +199 -0
- package/src/studio/branding.md +807 -0
- package/src/studio/components/EnlargeModal.tsx +138 -0
- package/src/studio/components/LogMessage.tsx +153 -0
- package/src/studio/components/MarkdownRenderer.tsx +410 -0
- package/src/studio/components/Sidebar.tsx +295 -0
- package/src/studio/components/ToolCard.tsx +139 -0
- package/src/studio/components/WidgetRenderer.tsx +346 -0
- package/src/studio/lib/api.ts +207 -0
- package/src/studio/lib/http-client-transport.ts +222 -0
- package/src/studio/lib/llm-service.ts +480 -0
- package/src/studio/lib/log-manager.ts +76 -0
- package/src/studio/lib/mcp-client.ts +258 -0
- package/src/studio/lib/store.ts +192 -0
- package/src/studio/lib/theme-provider.tsx +50 -0
- package/src/studio/lib/types.ts +107 -0
- package/src/studio/lib/widget-loader.ts +90 -0
- package/src/studio/middleware.ts +27 -0
- package/src/studio/next.config.js +38 -0
- package/src/studio/package.json +35 -0
- package/src/studio/postcss.config.mjs +10 -0
- package/src/studio/public/nitrocloud.png +0 -0
- package/src/studio/tailwind.config.ts +67 -0
- package/src/studio/tsconfig.json +42 -0
|
@@ -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
|
+
}
|