@payez/next-mvp 4.1.9 → 4.1.10
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/README.md +792 -792
- package/dist/api-handlers/auth/refresh.js +38 -15
- package/dist/auth/better-auth.js +7 -18
- package/dist/components/account/MobileNavDrawer.js +18 -18
- package/dist/components/account/UserAvatarMenu.js +1 -1
- package/dist/components/admin/AlertSettingsTab.js +40 -40
- package/dist/components/admin/LoggingSettingsTab.js +28 -28
- package/dist/lib/ensure-fresh-access-token.js +30 -7
- package/dist/lib/idp-fetch.js +25 -9
- package/dist/lib/session-store.d.ts +1 -0
- package/dist/lib/session-store.js +12 -8
- package/dist/pages/login/page.js +28 -28
- package/dist/pages/settings/EnhancedSettingsPage.js +17 -17
- package/package.json +893 -893
- package/scripts/dev-broker.ps1 +35 -35
- package/scripts/dev-local.ps1 +45 -45
- package/src/api/auth-handler.ts +548 -548
- package/src/api-handlers/account/change-password.ts +142 -142
- package/src/api-handlers/account/update-phone.ts +79 -79
- package/src/api-handlers/account/verify-email.ts +125 -125
- package/src/api-handlers/account/verify-sms.ts +125 -125
- package/src/api-handlers/admin/analytics.ts +443 -443
- package/src/api-handlers/admin/audit.ts +223 -223
- package/src/api-handlers/admin/index.ts +63 -63
- package/src/api-handlers/admin/redis-sessions.ts +251 -251
- package/src/api-handlers/admin/sessions.ts +318 -318
- package/src/api-handlers/admin/site-logs.ts +365 -365
- package/src/api-handlers/admin/users.ts +242 -242
- package/src/api-handlers/admin/vibe-data.ts +324 -324
- package/src/api-handlers/auth/refresh.ts +705 -682
- package/src/api-handlers/auth/signout.ts +199 -199
- package/src/api-handlers/auth/status.ts +20 -20
- package/src/api-handlers/auth/update-session.ts +110 -110
- package/src/api-handlers/auth/verify-code.ts +112 -112
- package/src/api-handlers/session/refresh-viability.ts +36 -36
- package/src/api-handlers/test/force-expire.ts +60 -60
- package/src/auth/auth-decision.ts +229 -229
- package/src/auth/better-auth.ts +7 -18
- package/src/auth/route-config.ts +219 -219
- package/src/auth/types/auth-types.ts +555 -555
- package/src/auth/types/index.ts +7 -7
- package/src/auth/utils/idp-client.ts +445 -445
- package/src/auth/utils/index.ts +6 -6
- package/src/auth/utils/token-utils.ts +243 -243
- package/src/client/AuthContext.tsx +143 -143
- package/src/client/fetch-with-auth.ts +47 -47
- package/src/components/SessionSync.tsx +138 -138
- package/src/components/SignalRHealthCheck.tsx +131 -131
- package/src/components/account/MobileNavDrawer.tsx +305 -305
- package/src/components/account/UserAvatarMenu.tsx +266 -266
- package/src/components/account/index.ts +13 -13
- package/src/components/admin/AlertSettingsTab.tsx +728 -728
- package/src/components/admin/AnalyticsTab.tsx +703 -703
- package/src/components/admin/DataBrowserTab.tsx +505 -505
- package/src/components/admin/LoggingSettingsTab.tsx +665 -665
- package/src/components/admin/SessionsTab.tsx +414 -414
- package/src/components/admin/StatsTab.tsx +379 -379
- package/src/components/admin/VibeAdminLayout.tsx +187 -187
- package/src/components/admin/index.ts +59 -59
- package/src/components/auth/FederatedAuthSection.tsx +95 -95
- package/src/components/auth/ModeAwareLoginPage.tsx +135 -135
- package/src/components/auth/ModeAwareSignupPage.tsx +267 -267
- package/src/components/auth/TraditionalAuthSection.tsx +99 -99
- package/src/components/recovery/CompleteStep.tsx +36 -36
- package/src/components/recovery/InitiateRecoveryStep.tsx +68 -68
- package/src/components/recovery/SelectMethodStep.tsx +73 -73
- package/src/components/recovery/SetPasswordStep.tsx +97 -97
- package/src/components/recovery/VerifyCodeStep.tsx +90 -90
- package/src/components/reserved/ReservedRecoveryWarning.tsx +160 -160
- package/src/components/reserved/ReservedStatusBox.tsx +118 -118
- package/src/components/ui/BetaBadge.tsx +58 -58
- package/src/components/ui/Footer.tsx +93 -93
- package/src/config/env.ts +57 -57
- package/src/config/logger.ts +62 -62
- package/src/config/vibe-log-transport.ts +264 -264
- package/src/hooks/useAuth.ts +117 -117
- package/src/hooks/useAuthSettings.ts +97 -97
- package/src/hooks/useAvailableProviders.ts +120 -120
- package/src/hooks/usePasswordValidation.ts +127 -127
- package/src/hooks/usePublicAuthSettings.ts +149 -149
- package/src/hooks/useSessionExpiration.ts +101 -101
- package/src/hooks/useViabilitySession.ts +336 -336
- package/src/index.ts +60 -60
- package/src/lib/api-handler.ts +643 -643
- package/src/lib/app-slug.ts +178 -178
- package/src/lib/ensure-fresh-access-token.ts +358 -335
- package/src/lib/geolocation.ts +265 -265
- package/src/lib/idp-fetch.ts +26 -10
- package/src/lib/jwt-decode.ts +82 -82
- package/src/lib/roles.ts +177 -177
- package/src/lib/session-store.ts +4 -1
- package/src/lib/site-logger.ts +245 -245
- package/src/lib/standardized-client-api.ts +901 -901
- package/src/lib/test-aware-get-token.ts +41 -41
- package/src/lib/user-agent-parser.ts +252 -252
- package/src/logging/api/admin-analytics.ts +51 -51
- package/src/logging/api/audit-log.ts +53 -53
- package/src/logging/components/AdminAnalyticsLayout.tsx +49 -49
- package/src/logging/components/AuditLogViewer.tsx +125 -125
- package/src/logging/components/ErrorMetricsCard.tsx +98 -98
- package/src/logging/components/HealthMetricsCard.tsx +70 -70
- package/src/logging/hooks/useAdminAnalytics.ts +22 -22
- package/src/logging/hooks/useAuditLog.ts +24 -24
- package/src/logging/hooks/useErrorMetrics.ts +40 -40
- package/src/logging/hooks/useHealthMetrics.ts +44 -44
- package/src/logging/index.ts +18 -18
- package/src/logging/types/analytics.ts +81 -81
- package/src/logging/types/audit.ts +31 -31
- package/src/logging/types/index.ts +3 -3
- package/src/middleware/create-middleware.ts +663 -663
- package/src/middleware/rbac-check.ts +279 -279
- package/src/middleware/twofa-presets.ts +224 -224
- package/src/pages/admin-login/page.tsx +221 -221
- package/src/pages/admin-page-permissions/PagePermissionsAdminPage.tsx +527 -527
- package/src/pages/admin-page-permissions/index.ts +7 -7
- package/src/pages/admin-roles/RolesAdminPage.tsx +533 -533
- package/src/pages/admin-roles/modals.tsx +469 -469
- package/src/pages/client-admin/ClientSiteAdminPage.tsx +382 -382
- package/src/pages/client-admin/index.ts +33 -33
- package/src/pages/coming-soon/page.tsx +83 -83
- package/src/pages/login/page.tsx +456 -456
- package/src/pages/profile/EnhancedProfilePage.tsx +485 -485
- package/src/pages/profile/index.ts +9 -9
- package/src/pages/profile/page.tsx +166 -166
- package/src/pages/recovery/page.tsx +234 -234
- package/src/pages/roles/MyRolesPage.tsx +211 -211
- package/src/pages/roles/components.tsx +294 -294
- package/src/pages/roles/index.ts +17 -17
- package/src/pages/security/EnhancedSecurityPage.tsx +565 -565
- package/src/pages/security/index.ts +9 -9
- package/src/pages/security/page.tsx +507 -507
- package/src/pages/settings/EnhancedSettingsPage.tsx +642 -642
- package/src/pages/settings/index.ts +9 -9
- package/src/pages/settings/page.tsx +47 -47
- package/src/pages/showcase/ShowcasePage.tsx +532 -532
- package/src/pages/showcase/index.ts +13 -13
- package/src/pages/test-env/EmergencyLogoutPage.tsx +180 -180
- package/src/pages/test-env/JwtInspectPage.tsx +456 -456
- package/src/pages/test-env/RefreshTokenPage.tsx +157 -157
- package/src/pages/test-env/TestEnvPage.tsx +118 -118
- package/src/pages/test-env/index.ts +25 -25
- package/src/pages/verify-code/page.tsx +652 -652
- package/src/routes/account/update-phone.ts +13 -13
- package/src/routes/auth/logout.ts +108 -108
- package/src/routes/auth/nextauth.ts +45 -45
- package/src/routes/auth/settings.ts +75 -75
- package/src/routes/auth/viability.ts +224 -224
- package/src/server/auth-guard.ts +170 -170
- package/src/server/slim-middleware.ts +117 -117
- package/src/server/with-auth.ts +93 -93
- package/src/stores/authStore.ts +1899 -1899
- package/src/templates/instrumentation.ts +41 -41
- package/src/utils/logout.ts +30 -30
- package/src/vibe/client.ts +590 -590
- package/src/vibe/enterprise-auth.ts +208 -208
- package/src/vibe/errors.ts +185 -185
- package/src/vibe/generic.ts +431 -431
- package/src/vibe/hooks/index.ts +367 -367
- package/src/vibe/index.ts +132 -132
- package/src/vibe/sessions.ts +551 -551
- package/src/vibe/types.ts +577 -577
|
@@ -1,379 +1,379 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* =============================================================================
|
|
3
|
-
* VIBE ADMIN STATS TAB
|
|
4
|
-
* =============================================================================
|
|
5
|
-
*
|
|
6
|
-
* Generic dashboard stats overview tab.
|
|
7
|
-
* Shows high-level metrics about users, sessions, and system health.
|
|
8
|
-
*
|
|
9
|
-
* =============================================================================
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
'use client';
|
|
13
|
-
|
|
14
|
-
import React, { useState, useEffect, useCallback, ReactNode } from 'react';
|
|
15
|
-
import {
|
|
16
|
-
Users,
|
|
17
|
-
Activity,
|
|
18
|
-
Clock,
|
|
19
|
-
TrendingUp,
|
|
20
|
-
Shield,
|
|
21
|
-
Server,
|
|
22
|
-
type LucideIcon,
|
|
23
|
-
} from 'lucide-react';
|
|
24
|
-
|
|
25
|
-
// -----------------------------------------------------------------------------
|
|
26
|
-
// TYPES
|
|
27
|
-
// -----------------------------------------------------------------------------
|
|
28
|
-
|
|
29
|
-
export interface StatCard {
|
|
30
|
-
id: string;
|
|
31
|
-
label: string;
|
|
32
|
-
value: string | number;
|
|
33
|
-
change?: string;
|
|
34
|
-
changeType?: 'positive' | 'negative' | 'neutral';
|
|
35
|
-
icon?: LucideIcon;
|
|
36
|
-
color?: 'blue' | 'green' | 'purple' | 'orange' | 'red' | 'cyan';
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export interface QuickAction {
|
|
40
|
-
id: string;
|
|
41
|
-
label: string;
|
|
42
|
-
description: string;
|
|
43
|
-
onClick: () => void;
|
|
44
|
-
icon?: LucideIcon;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export interface RecentActivity {
|
|
48
|
-
id: string;
|
|
49
|
-
type: string;
|
|
50
|
-
message: string;
|
|
51
|
-
timestamp: string;
|
|
52
|
-
user?: string;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export interface SystemStatus {
|
|
56
|
-
service: string;
|
|
57
|
-
status: 'healthy' | 'degraded' | 'down';
|
|
58
|
-
latency?: number;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export interface StatsTabProps {
|
|
62
|
-
isDark?: boolean;
|
|
63
|
-
/** API endpoint to fetch stats (default: /api/admin/stats) */
|
|
64
|
-
apiBasePath?: string;
|
|
65
|
-
/** Custom stat cards to display */
|
|
66
|
-
customStats?: StatCard[];
|
|
67
|
-
/** Quick actions shown in the sidebar */
|
|
68
|
-
quickActions?: QuickAction[];
|
|
69
|
-
/** Additional content to render below stats */
|
|
70
|
-
children?: ReactNode;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// -----------------------------------------------------------------------------
|
|
74
|
-
// COMPONENT
|
|
75
|
-
// -----------------------------------------------------------------------------
|
|
76
|
-
|
|
77
|
-
export function StatsTab({
|
|
78
|
-
isDark = true,
|
|
79
|
-
apiBasePath = '/api/admin/stats',
|
|
80
|
-
customStats,
|
|
81
|
-
quickActions,
|
|
82
|
-
children,
|
|
83
|
-
}: StatsTabProps) {
|
|
84
|
-
const [stats, setStats] = useState<StatCard[]>([]);
|
|
85
|
-
const [recentActivity, setRecentActivity] = useState<RecentActivity[]>([]);
|
|
86
|
-
const [systemStatus, setSystemStatus] = useState<SystemStatus[]>([]);
|
|
87
|
-
const [loading, setLoading] = useState(true);
|
|
88
|
-
const [error, setError] = useState<string | null>(null);
|
|
89
|
-
|
|
90
|
-
const themeClasses = {
|
|
91
|
-
cardBg: isDark ? 'bg-slate-800 border-slate-700' : 'bg-white border-gray-200 shadow-sm',
|
|
92
|
-
textPrimary: isDark ? 'text-white' : 'text-gray-900',
|
|
93
|
-
textSecondary: isDark ? 'text-gray-400' : 'text-gray-600',
|
|
94
|
-
textMuted: isDark ? 'text-gray-500' : 'text-gray-500',
|
|
95
|
-
};
|
|
96
|
-
|
|
97
|
-
const colorClasses: Record<string, { bg: string; text: string; icon: string }> = {
|
|
98
|
-
blue: { bg: 'bg-blue-500/10', text: 'text-blue-400', icon: 'text-blue-400' },
|
|
99
|
-
green: { bg: 'bg-emerald-500/10', text: 'text-emerald-400', icon: 'text-emerald-400' },
|
|
100
|
-
purple: { bg: 'bg-purple-500/10', text: 'text-purple-400', icon: 'text-purple-400' },
|
|
101
|
-
orange: { bg: 'bg-orange-500/10', text: 'text-orange-400', icon: 'text-orange-400' },
|
|
102
|
-
red: { bg: 'bg-red-500/10', text: 'text-red-400', icon: 'text-red-400' },
|
|
103
|
-
cyan: { bg: 'bg-cyan-500/10', text: 'text-cyan-400', icon: 'text-cyan-400' },
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
const defaultIcons: Record<string, LucideIcon> = {
|
|
107
|
-
users: Users,
|
|
108
|
-
sessions: Activity,
|
|
109
|
-
uptime: Clock,
|
|
110
|
-
growth: TrendingUp,
|
|
111
|
-
security: Shield,
|
|
112
|
-
system: Server,
|
|
113
|
-
};
|
|
114
|
-
|
|
115
|
-
const fetchStats = useCallback(async () => {
|
|
116
|
-
setLoading(true);
|
|
117
|
-
setError(null);
|
|
118
|
-
|
|
119
|
-
try {
|
|
120
|
-
const res = await fetch(apiBasePath);
|
|
121
|
-
if (!res.ok) throw new Error('Failed to fetch stats');
|
|
122
|
-
|
|
123
|
-
const data = await res.json();
|
|
124
|
-
|
|
125
|
-
// Map API response to stat cards
|
|
126
|
-
const apiStats: StatCard[] = [];
|
|
127
|
-
|
|
128
|
-
if (data.totalUsers !== undefined) {
|
|
129
|
-
apiStats.push({
|
|
130
|
-
id: 'users',
|
|
131
|
-
label: 'Total Users',
|
|
132
|
-
value: data.totalUsers,
|
|
133
|
-
change: data.userGrowth ? `+${data.userGrowth}%` : undefined,
|
|
134
|
-
changeType: 'positive',
|
|
135
|
-
icon: Users,
|
|
136
|
-
color: 'blue',
|
|
137
|
-
});
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
if (data.activeSessions !== undefined) {
|
|
141
|
-
apiStats.push({
|
|
142
|
-
id: 'sessions',
|
|
143
|
-
label: 'Active Sessions',
|
|
144
|
-
value: data.activeSessions,
|
|
145
|
-
icon: Activity,
|
|
146
|
-
color: 'green',
|
|
147
|
-
});
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
if (data.todayLogins !== undefined) {
|
|
151
|
-
apiStats.push({
|
|
152
|
-
id: 'logins',
|
|
153
|
-
label: 'Today Logins',
|
|
154
|
-
value: data.todayLogins,
|
|
155
|
-
icon: Clock,
|
|
156
|
-
color: 'purple',
|
|
157
|
-
});
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
if (data.newUsersToday !== undefined) {
|
|
161
|
-
apiStats.push({
|
|
162
|
-
id: 'new_users',
|
|
163
|
-
label: 'New Users Today',
|
|
164
|
-
value: data.newUsersToday,
|
|
165
|
-
icon: TrendingUp,
|
|
166
|
-
color: 'orange',
|
|
167
|
-
});
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// Merge custom stats with API stats
|
|
171
|
-
setStats(customStats ? [...apiStats, ...customStats] : apiStats);
|
|
172
|
-
|
|
173
|
-
// Set recent activity if available
|
|
174
|
-
if (data.recentActivity) {
|
|
175
|
-
setRecentActivity(data.recentActivity);
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// Set system status if available
|
|
179
|
-
if (data.systemStatus) {
|
|
180
|
-
setSystemStatus(data.systemStatus);
|
|
181
|
-
}
|
|
182
|
-
} catch (err: any) {
|
|
183
|
-
setError(err.message);
|
|
184
|
-
// Use custom stats as fallback
|
|
185
|
-
if (customStats) {
|
|
186
|
-
setStats(customStats);
|
|
187
|
-
}
|
|
188
|
-
} finally {
|
|
189
|
-
setLoading(false);
|
|
190
|
-
}
|
|
191
|
-
}, [apiBasePath, customStats]);
|
|
192
|
-
|
|
193
|
-
useEffect(() => {
|
|
194
|
-
fetchStats();
|
|
195
|
-
// Refresh stats every 60 seconds
|
|
196
|
-
const interval = setInterval(fetchStats, 60000);
|
|
197
|
-
return () => clearInterval(interval);
|
|
198
|
-
}, [fetchStats]);
|
|
199
|
-
|
|
200
|
-
const getTimeAgo = (dateStr: string) => {
|
|
201
|
-
const now = new Date();
|
|
202
|
-
const date = new Date(dateStr);
|
|
203
|
-
const diffMs = now.getTime() - date.getTime();
|
|
204
|
-
const diffSec = Math.floor(diffMs / 1000);
|
|
205
|
-
const diffMin = Math.floor(diffSec / 60);
|
|
206
|
-
const diffHour = Math.floor(diffMin / 60);
|
|
207
|
-
|
|
208
|
-
if (diffSec < 60) return 'Just now';
|
|
209
|
-
if (diffMin < 60) return `${diffMin}m ago`;
|
|
210
|
-
if (diffHour < 24) return `${diffHour}h ago`;
|
|
211
|
-
return date.toLocaleDateString();
|
|
212
|
-
};
|
|
213
|
-
|
|
214
|
-
if (loading && stats.length === 0) {
|
|
215
|
-
return (
|
|
216
|
-
<div className="flex items-center justify-center py-12">
|
|
217
|
-
<svg className="animate-spin h-8 w-8 text-blue-400" viewBox="0 0 24 24" fill="none">
|
|
218
|
-
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
219
|
-
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
|
220
|
-
</svg>
|
|
221
|
-
</div>
|
|
222
|
-
);
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
return (
|
|
226
|
-
<div className="space-y-6">
|
|
227
|
-
{/* Error Banner */}
|
|
228
|
-
{error && (
|
|
229
|
-
<div className={`p-4 rounded-lg ${isDark ? 'bg-red-900/30 border-red-700' : 'bg-red-50 border-red-300'} border ${isDark ? 'text-red-300' : 'text-red-700'}`}>
|
|
230
|
-
{error}
|
|
231
|
-
<button onClick={fetchStats} className="ml-4 underline">Retry</button>
|
|
232
|
-
</div>
|
|
233
|
-
)}
|
|
234
|
-
|
|
235
|
-
{/* Stats Grid */}
|
|
236
|
-
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
237
|
-
{stats.map((stat) => {
|
|
238
|
-
const Icon = stat.icon || defaultIcons[stat.id] || Activity;
|
|
239
|
-
const colors = colorClasses[stat.color || 'blue'];
|
|
240
|
-
|
|
241
|
-
return (
|
|
242
|
-
<div key={stat.id} className={`${themeClasses.cardBg} border rounded-xl p-5`}>
|
|
243
|
-
<div className="flex items-start justify-between">
|
|
244
|
-
<div>
|
|
245
|
-
<p className={`text-sm ${themeClasses.textSecondary}`}>{stat.label}</p>
|
|
246
|
-
<p className={`text-3xl font-bold ${themeClasses.textPrimary} mt-1`}>
|
|
247
|
-
{typeof stat.value === 'number' ? stat.value.toLocaleString() : stat.value}
|
|
248
|
-
</p>
|
|
249
|
-
{stat.change && (
|
|
250
|
-
<p className={`text-sm mt-1 ${
|
|
251
|
-
stat.changeType === 'positive' ? 'text-emerald-400' :
|
|
252
|
-
stat.changeType === 'negative' ? 'text-red-400' :
|
|
253
|
-
themeClasses.textMuted
|
|
254
|
-
}`}>
|
|
255
|
-
{stat.change}
|
|
256
|
-
</p>
|
|
257
|
-
)}
|
|
258
|
-
</div>
|
|
259
|
-
<div className={`p-3 rounded-lg ${colors.bg}`}>
|
|
260
|
-
<Icon className={`w-6 h-6 ${colors.icon}`} />
|
|
261
|
-
</div>
|
|
262
|
-
</div>
|
|
263
|
-
</div>
|
|
264
|
-
);
|
|
265
|
-
})}
|
|
266
|
-
</div>
|
|
267
|
-
|
|
268
|
-
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
269
|
-
{/* Recent Activity */}
|
|
270
|
-
<div className={`lg:col-span-2 ${themeClasses.cardBg} border rounded-xl`}>
|
|
271
|
-
<div className={`px-5 py-4 border-b ${isDark ? 'border-slate-700' : 'border-gray-200'}`}>
|
|
272
|
-
<h3 className={`font-semibold ${themeClasses.textPrimary}`}>Recent Activity</h3>
|
|
273
|
-
</div>
|
|
274
|
-
<div className="p-5">
|
|
275
|
-
{recentActivity.length > 0 ? (
|
|
276
|
-
<div className="space-y-4">
|
|
277
|
-
{recentActivity.map((activity) => (
|
|
278
|
-
<div key={activity.id} className="flex items-start gap-3">
|
|
279
|
-
<div className={`w-2 h-2 rounded-full mt-2 ${
|
|
280
|
-
activity.type === 'login' ? 'bg-green-400' :
|
|
281
|
-
activity.type === 'signup' ? 'bg-blue-400' :
|
|
282
|
-
activity.type === 'error' ? 'bg-red-400' :
|
|
283
|
-
'bg-gray-400'
|
|
284
|
-
}`} />
|
|
285
|
-
<div className="flex-1 min-w-0">
|
|
286
|
-
<p className={`text-sm ${themeClasses.textPrimary}`}>{activity.message}</p>
|
|
287
|
-
<div className={`flex items-center gap-2 text-xs ${themeClasses.textMuted} mt-1`}>
|
|
288
|
-
{activity.user && <span>{activity.user}</span>}
|
|
289
|
-
<span>{getTimeAgo(activity.timestamp)}</span>
|
|
290
|
-
</div>
|
|
291
|
-
</div>
|
|
292
|
-
</div>
|
|
293
|
-
))}
|
|
294
|
-
</div>
|
|
295
|
-
) : (
|
|
296
|
-
<p className={`text-center py-8 ${themeClasses.textMuted}`}>
|
|
297
|
-
No recent activity
|
|
298
|
-
</p>
|
|
299
|
-
)}
|
|
300
|
-
</div>
|
|
301
|
-
</div>
|
|
302
|
-
|
|
303
|
-
{/* Quick Actions & System Status */}
|
|
304
|
-
<div className="space-y-6">
|
|
305
|
-
{/* Quick Actions */}
|
|
306
|
-
{quickActions && quickActions.length > 0 && (
|
|
307
|
-
<div className={`${themeClasses.cardBg} border rounded-xl`}>
|
|
308
|
-
<div className={`px-5 py-4 border-b ${isDark ? 'border-slate-700' : 'border-gray-200'}`}>
|
|
309
|
-
<h3 className={`font-semibold ${themeClasses.textPrimary}`}>Quick Actions</h3>
|
|
310
|
-
</div>
|
|
311
|
-
<div className="p-3">
|
|
312
|
-
{quickActions.map((action) => {
|
|
313
|
-
const Icon = action.icon || Activity;
|
|
314
|
-
return (
|
|
315
|
-
<button
|
|
316
|
-
key={action.id}
|
|
317
|
-
onClick={action.onClick}
|
|
318
|
-
className={`w-full text-left p-3 rounded-lg transition-colors ${
|
|
319
|
-
isDark ? 'hover:bg-slate-700' : 'hover:bg-gray-100'
|
|
320
|
-
}`}
|
|
321
|
-
>
|
|
322
|
-
<div className="flex items-center gap-3">
|
|
323
|
-
<Icon className={`w-5 h-5 ${themeClasses.textSecondary}`} />
|
|
324
|
-
<div>
|
|
325
|
-
<p className={`font-medium ${themeClasses.textPrimary}`}>{action.label}</p>
|
|
326
|
-
<p className={`text-xs ${themeClasses.textMuted}`}>{action.description}</p>
|
|
327
|
-
</div>
|
|
328
|
-
</div>
|
|
329
|
-
</button>
|
|
330
|
-
);
|
|
331
|
-
})}
|
|
332
|
-
</div>
|
|
333
|
-
</div>
|
|
334
|
-
)}
|
|
335
|
-
|
|
336
|
-
{/* System Status */}
|
|
337
|
-
{systemStatus.length > 0 && (
|
|
338
|
-
<div className={`${themeClasses.cardBg} border rounded-xl`}>
|
|
339
|
-
<div className={`px-5 py-4 border-b ${isDark ? 'border-slate-700' : 'border-gray-200'}`}>
|
|
340
|
-
<h3 className={`font-semibold ${themeClasses.textPrimary}`}>System Status</h3>
|
|
341
|
-
</div>
|
|
342
|
-
<div className="p-5 space-y-3">
|
|
343
|
-
{systemStatus.map((service) => (
|
|
344
|
-
<div key={service.service} className="flex items-center justify-between">
|
|
345
|
-
<div className="flex items-center gap-2">
|
|
346
|
-
<div className={`w-2 h-2 rounded-full ${
|
|
347
|
-
service.status === 'healthy' ? 'bg-emerald-400' :
|
|
348
|
-
service.status === 'degraded' ? 'bg-amber-400' :
|
|
349
|
-
'bg-red-400'
|
|
350
|
-
}`} />
|
|
351
|
-
<span className={themeClasses.textPrimary}>{service.service}</span>
|
|
352
|
-
</div>
|
|
353
|
-
<div className="flex items-center gap-2">
|
|
354
|
-
{service.latency !== undefined && (
|
|
355
|
-
<span className={`text-xs ${themeClasses.textMuted}`}>{service.latency}ms</span>
|
|
356
|
-
)}
|
|
357
|
-
<span className={`text-xs px-2 py-0.5 rounded ${
|
|
358
|
-
service.status === 'healthy' ? 'bg-emerald-500/20 text-emerald-400' :
|
|
359
|
-
service.status === 'degraded' ? 'bg-amber-500/20 text-amber-400' :
|
|
360
|
-
'bg-red-500/20 text-red-400'
|
|
361
|
-
}`}>
|
|
362
|
-
{service.status}
|
|
363
|
-
</span>
|
|
364
|
-
</div>
|
|
365
|
-
</div>
|
|
366
|
-
))}
|
|
367
|
-
</div>
|
|
368
|
-
</div>
|
|
369
|
-
)}
|
|
370
|
-
</div>
|
|
371
|
-
</div>
|
|
372
|
-
|
|
373
|
-
{/* Custom children content */}
|
|
374
|
-
{children}
|
|
375
|
-
</div>
|
|
376
|
-
);
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
export default StatsTab;
|
|
1
|
+
/**
|
|
2
|
+
* =============================================================================
|
|
3
|
+
* VIBE ADMIN STATS TAB
|
|
4
|
+
* =============================================================================
|
|
5
|
+
*
|
|
6
|
+
* Generic dashboard stats overview tab.
|
|
7
|
+
* Shows high-level metrics about users, sessions, and system health.
|
|
8
|
+
*
|
|
9
|
+
* =============================================================================
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
'use client';
|
|
13
|
+
|
|
14
|
+
import React, { useState, useEffect, useCallback, ReactNode } from 'react';
|
|
15
|
+
import {
|
|
16
|
+
Users,
|
|
17
|
+
Activity,
|
|
18
|
+
Clock,
|
|
19
|
+
TrendingUp,
|
|
20
|
+
Shield,
|
|
21
|
+
Server,
|
|
22
|
+
type LucideIcon,
|
|
23
|
+
} from 'lucide-react';
|
|
24
|
+
|
|
25
|
+
// -----------------------------------------------------------------------------
|
|
26
|
+
// TYPES
|
|
27
|
+
// -----------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
export interface StatCard {
|
|
30
|
+
id: string;
|
|
31
|
+
label: string;
|
|
32
|
+
value: string | number;
|
|
33
|
+
change?: string;
|
|
34
|
+
changeType?: 'positive' | 'negative' | 'neutral';
|
|
35
|
+
icon?: LucideIcon;
|
|
36
|
+
color?: 'blue' | 'green' | 'purple' | 'orange' | 'red' | 'cyan';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface QuickAction {
|
|
40
|
+
id: string;
|
|
41
|
+
label: string;
|
|
42
|
+
description: string;
|
|
43
|
+
onClick: () => void;
|
|
44
|
+
icon?: LucideIcon;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface RecentActivity {
|
|
48
|
+
id: string;
|
|
49
|
+
type: string;
|
|
50
|
+
message: string;
|
|
51
|
+
timestamp: string;
|
|
52
|
+
user?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface SystemStatus {
|
|
56
|
+
service: string;
|
|
57
|
+
status: 'healthy' | 'degraded' | 'down';
|
|
58
|
+
latency?: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface StatsTabProps {
|
|
62
|
+
isDark?: boolean;
|
|
63
|
+
/** API endpoint to fetch stats (default: /api/admin/stats) */
|
|
64
|
+
apiBasePath?: string;
|
|
65
|
+
/** Custom stat cards to display */
|
|
66
|
+
customStats?: StatCard[];
|
|
67
|
+
/** Quick actions shown in the sidebar */
|
|
68
|
+
quickActions?: QuickAction[];
|
|
69
|
+
/** Additional content to render below stats */
|
|
70
|
+
children?: ReactNode;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// -----------------------------------------------------------------------------
|
|
74
|
+
// COMPONENT
|
|
75
|
+
// -----------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
export function StatsTab({
|
|
78
|
+
isDark = true,
|
|
79
|
+
apiBasePath = '/api/admin/stats',
|
|
80
|
+
customStats,
|
|
81
|
+
quickActions,
|
|
82
|
+
children,
|
|
83
|
+
}: StatsTabProps) {
|
|
84
|
+
const [stats, setStats] = useState<StatCard[]>([]);
|
|
85
|
+
const [recentActivity, setRecentActivity] = useState<RecentActivity[]>([]);
|
|
86
|
+
const [systemStatus, setSystemStatus] = useState<SystemStatus[]>([]);
|
|
87
|
+
const [loading, setLoading] = useState(true);
|
|
88
|
+
const [error, setError] = useState<string | null>(null);
|
|
89
|
+
|
|
90
|
+
const themeClasses = {
|
|
91
|
+
cardBg: isDark ? 'bg-slate-800 border-slate-700' : 'bg-white border-gray-200 shadow-sm',
|
|
92
|
+
textPrimary: isDark ? 'text-white' : 'text-gray-900',
|
|
93
|
+
textSecondary: isDark ? 'text-gray-400' : 'text-gray-600',
|
|
94
|
+
textMuted: isDark ? 'text-gray-500' : 'text-gray-500',
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const colorClasses: Record<string, { bg: string; text: string; icon: string }> = {
|
|
98
|
+
blue: { bg: 'bg-blue-500/10', text: 'text-blue-400', icon: 'text-blue-400' },
|
|
99
|
+
green: { bg: 'bg-emerald-500/10', text: 'text-emerald-400', icon: 'text-emerald-400' },
|
|
100
|
+
purple: { bg: 'bg-purple-500/10', text: 'text-purple-400', icon: 'text-purple-400' },
|
|
101
|
+
orange: { bg: 'bg-orange-500/10', text: 'text-orange-400', icon: 'text-orange-400' },
|
|
102
|
+
red: { bg: 'bg-red-500/10', text: 'text-red-400', icon: 'text-red-400' },
|
|
103
|
+
cyan: { bg: 'bg-cyan-500/10', text: 'text-cyan-400', icon: 'text-cyan-400' },
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const defaultIcons: Record<string, LucideIcon> = {
|
|
107
|
+
users: Users,
|
|
108
|
+
sessions: Activity,
|
|
109
|
+
uptime: Clock,
|
|
110
|
+
growth: TrendingUp,
|
|
111
|
+
security: Shield,
|
|
112
|
+
system: Server,
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const fetchStats = useCallback(async () => {
|
|
116
|
+
setLoading(true);
|
|
117
|
+
setError(null);
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const res = await fetch(apiBasePath);
|
|
121
|
+
if (!res.ok) throw new Error('Failed to fetch stats');
|
|
122
|
+
|
|
123
|
+
const data = await res.json();
|
|
124
|
+
|
|
125
|
+
// Map API response to stat cards
|
|
126
|
+
const apiStats: StatCard[] = [];
|
|
127
|
+
|
|
128
|
+
if (data.totalUsers !== undefined) {
|
|
129
|
+
apiStats.push({
|
|
130
|
+
id: 'users',
|
|
131
|
+
label: 'Total Users',
|
|
132
|
+
value: data.totalUsers,
|
|
133
|
+
change: data.userGrowth ? `+${data.userGrowth}%` : undefined,
|
|
134
|
+
changeType: 'positive',
|
|
135
|
+
icon: Users,
|
|
136
|
+
color: 'blue',
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (data.activeSessions !== undefined) {
|
|
141
|
+
apiStats.push({
|
|
142
|
+
id: 'sessions',
|
|
143
|
+
label: 'Active Sessions',
|
|
144
|
+
value: data.activeSessions,
|
|
145
|
+
icon: Activity,
|
|
146
|
+
color: 'green',
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (data.todayLogins !== undefined) {
|
|
151
|
+
apiStats.push({
|
|
152
|
+
id: 'logins',
|
|
153
|
+
label: 'Today Logins',
|
|
154
|
+
value: data.todayLogins,
|
|
155
|
+
icon: Clock,
|
|
156
|
+
color: 'purple',
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (data.newUsersToday !== undefined) {
|
|
161
|
+
apiStats.push({
|
|
162
|
+
id: 'new_users',
|
|
163
|
+
label: 'New Users Today',
|
|
164
|
+
value: data.newUsersToday,
|
|
165
|
+
icon: TrendingUp,
|
|
166
|
+
color: 'orange',
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Merge custom stats with API stats
|
|
171
|
+
setStats(customStats ? [...apiStats, ...customStats] : apiStats);
|
|
172
|
+
|
|
173
|
+
// Set recent activity if available
|
|
174
|
+
if (data.recentActivity) {
|
|
175
|
+
setRecentActivity(data.recentActivity);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Set system status if available
|
|
179
|
+
if (data.systemStatus) {
|
|
180
|
+
setSystemStatus(data.systemStatus);
|
|
181
|
+
}
|
|
182
|
+
} catch (err: any) {
|
|
183
|
+
setError(err.message);
|
|
184
|
+
// Use custom stats as fallback
|
|
185
|
+
if (customStats) {
|
|
186
|
+
setStats(customStats);
|
|
187
|
+
}
|
|
188
|
+
} finally {
|
|
189
|
+
setLoading(false);
|
|
190
|
+
}
|
|
191
|
+
}, [apiBasePath, customStats]);
|
|
192
|
+
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
fetchStats();
|
|
195
|
+
// Refresh stats every 60 seconds
|
|
196
|
+
const interval = setInterval(fetchStats, 60000);
|
|
197
|
+
return () => clearInterval(interval);
|
|
198
|
+
}, [fetchStats]);
|
|
199
|
+
|
|
200
|
+
const getTimeAgo = (dateStr: string) => {
|
|
201
|
+
const now = new Date();
|
|
202
|
+
const date = new Date(dateStr);
|
|
203
|
+
const diffMs = now.getTime() - date.getTime();
|
|
204
|
+
const diffSec = Math.floor(diffMs / 1000);
|
|
205
|
+
const diffMin = Math.floor(diffSec / 60);
|
|
206
|
+
const diffHour = Math.floor(diffMin / 60);
|
|
207
|
+
|
|
208
|
+
if (diffSec < 60) return 'Just now';
|
|
209
|
+
if (diffMin < 60) return `${diffMin}m ago`;
|
|
210
|
+
if (diffHour < 24) return `${diffHour}h ago`;
|
|
211
|
+
return date.toLocaleDateString();
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
if (loading && stats.length === 0) {
|
|
215
|
+
return (
|
|
216
|
+
<div className="flex items-center justify-center py-12">
|
|
217
|
+
<svg className="animate-spin h-8 w-8 text-blue-400" viewBox="0 0 24 24" fill="none">
|
|
218
|
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
219
|
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
|
220
|
+
</svg>
|
|
221
|
+
</div>
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return (
|
|
226
|
+
<div className="space-y-6">
|
|
227
|
+
{/* Error Banner */}
|
|
228
|
+
{error && (
|
|
229
|
+
<div className={`p-4 rounded-lg ${isDark ? 'bg-red-900/30 border-red-700' : 'bg-red-50 border-red-300'} border ${isDark ? 'text-red-300' : 'text-red-700'}`}>
|
|
230
|
+
{error}
|
|
231
|
+
<button onClick={fetchStats} className="ml-4 underline">Retry</button>
|
|
232
|
+
</div>
|
|
233
|
+
)}
|
|
234
|
+
|
|
235
|
+
{/* Stats Grid */}
|
|
236
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
237
|
+
{stats.map((stat) => {
|
|
238
|
+
const Icon = stat.icon || defaultIcons[stat.id] || Activity;
|
|
239
|
+
const colors = colorClasses[stat.color || 'blue'];
|
|
240
|
+
|
|
241
|
+
return (
|
|
242
|
+
<div key={stat.id} className={`${themeClasses.cardBg} border rounded-xl p-5`}>
|
|
243
|
+
<div className="flex items-start justify-between">
|
|
244
|
+
<div>
|
|
245
|
+
<p className={`text-sm ${themeClasses.textSecondary}`}>{stat.label}</p>
|
|
246
|
+
<p className={`text-3xl font-bold ${themeClasses.textPrimary} mt-1`}>
|
|
247
|
+
{typeof stat.value === 'number' ? stat.value.toLocaleString() : stat.value}
|
|
248
|
+
</p>
|
|
249
|
+
{stat.change && (
|
|
250
|
+
<p className={`text-sm mt-1 ${
|
|
251
|
+
stat.changeType === 'positive' ? 'text-emerald-400' :
|
|
252
|
+
stat.changeType === 'negative' ? 'text-red-400' :
|
|
253
|
+
themeClasses.textMuted
|
|
254
|
+
}`}>
|
|
255
|
+
{stat.change}
|
|
256
|
+
</p>
|
|
257
|
+
)}
|
|
258
|
+
</div>
|
|
259
|
+
<div className={`p-3 rounded-lg ${colors.bg}`}>
|
|
260
|
+
<Icon className={`w-6 h-6 ${colors.icon}`} />
|
|
261
|
+
</div>
|
|
262
|
+
</div>
|
|
263
|
+
</div>
|
|
264
|
+
);
|
|
265
|
+
})}
|
|
266
|
+
</div>
|
|
267
|
+
|
|
268
|
+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
269
|
+
{/* Recent Activity */}
|
|
270
|
+
<div className={`lg:col-span-2 ${themeClasses.cardBg} border rounded-xl`}>
|
|
271
|
+
<div className={`px-5 py-4 border-b ${isDark ? 'border-slate-700' : 'border-gray-200'}`}>
|
|
272
|
+
<h3 className={`font-semibold ${themeClasses.textPrimary}`}>Recent Activity</h3>
|
|
273
|
+
</div>
|
|
274
|
+
<div className="p-5">
|
|
275
|
+
{recentActivity.length > 0 ? (
|
|
276
|
+
<div className="space-y-4">
|
|
277
|
+
{recentActivity.map((activity) => (
|
|
278
|
+
<div key={activity.id} className="flex items-start gap-3">
|
|
279
|
+
<div className={`w-2 h-2 rounded-full mt-2 ${
|
|
280
|
+
activity.type === 'login' ? 'bg-green-400' :
|
|
281
|
+
activity.type === 'signup' ? 'bg-blue-400' :
|
|
282
|
+
activity.type === 'error' ? 'bg-red-400' :
|
|
283
|
+
'bg-gray-400'
|
|
284
|
+
}`} />
|
|
285
|
+
<div className="flex-1 min-w-0">
|
|
286
|
+
<p className={`text-sm ${themeClasses.textPrimary}`}>{activity.message}</p>
|
|
287
|
+
<div className={`flex items-center gap-2 text-xs ${themeClasses.textMuted} mt-1`}>
|
|
288
|
+
{activity.user && <span>{activity.user}</span>}
|
|
289
|
+
<span>{getTimeAgo(activity.timestamp)}</span>
|
|
290
|
+
</div>
|
|
291
|
+
</div>
|
|
292
|
+
</div>
|
|
293
|
+
))}
|
|
294
|
+
</div>
|
|
295
|
+
) : (
|
|
296
|
+
<p className={`text-center py-8 ${themeClasses.textMuted}`}>
|
|
297
|
+
No recent activity
|
|
298
|
+
</p>
|
|
299
|
+
)}
|
|
300
|
+
</div>
|
|
301
|
+
</div>
|
|
302
|
+
|
|
303
|
+
{/* Quick Actions & System Status */}
|
|
304
|
+
<div className="space-y-6">
|
|
305
|
+
{/* Quick Actions */}
|
|
306
|
+
{quickActions && quickActions.length > 0 && (
|
|
307
|
+
<div className={`${themeClasses.cardBg} border rounded-xl`}>
|
|
308
|
+
<div className={`px-5 py-4 border-b ${isDark ? 'border-slate-700' : 'border-gray-200'}`}>
|
|
309
|
+
<h3 className={`font-semibold ${themeClasses.textPrimary}`}>Quick Actions</h3>
|
|
310
|
+
</div>
|
|
311
|
+
<div className="p-3">
|
|
312
|
+
{quickActions.map((action) => {
|
|
313
|
+
const Icon = action.icon || Activity;
|
|
314
|
+
return (
|
|
315
|
+
<button
|
|
316
|
+
key={action.id}
|
|
317
|
+
onClick={action.onClick}
|
|
318
|
+
className={`w-full text-left p-3 rounded-lg transition-colors ${
|
|
319
|
+
isDark ? 'hover:bg-slate-700' : 'hover:bg-gray-100'
|
|
320
|
+
}`}
|
|
321
|
+
>
|
|
322
|
+
<div className="flex items-center gap-3">
|
|
323
|
+
<Icon className={`w-5 h-5 ${themeClasses.textSecondary}`} />
|
|
324
|
+
<div>
|
|
325
|
+
<p className={`font-medium ${themeClasses.textPrimary}`}>{action.label}</p>
|
|
326
|
+
<p className={`text-xs ${themeClasses.textMuted}`}>{action.description}</p>
|
|
327
|
+
</div>
|
|
328
|
+
</div>
|
|
329
|
+
</button>
|
|
330
|
+
);
|
|
331
|
+
})}
|
|
332
|
+
</div>
|
|
333
|
+
</div>
|
|
334
|
+
)}
|
|
335
|
+
|
|
336
|
+
{/* System Status */}
|
|
337
|
+
{systemStatus.length > 0 && (
|
|
338
|
+
<div className={`${themeClasses.cardBg} border rounded-xl`}>
|
|
339
|
+
<div className={`px-5 py-4 border-b ${isDark ? 'border-slate-700' : 'border-gray-200'}`}>
|
|
340
|
+
<h3 className={`font-semibold ${themeClasses.textPrimary}`}>System Status</h3>
|
|
341
|
+
</div>
|
|
342
|
+
<div className="p-5 space-y-3">
|
|
343
|
+
{systemStatus.map((service) => (
|
|
344
|
+
<div key={service.service} className="flex items-center justify-between">
|
|
345
|
+
<div className="flex items-center gap-2">
|
|
346
|
+
<div className={`w-2 h-2 rounded-full ${
|
|
347
|
+
service.status === 'healthy' ? 'bg-emerald-400' :
|
|
348
|
+
service.status === 'degraded' ? 'bg-amber-400' :
|
|
349
|
+
'bg-red-400'
|
|
350
|
+
}`} />
|
|
351
|
+
<span className={themeClasses.textPrimary}>{service.service}</span>
|
|
352
|
+
</div>
|
|
353
|
+
<div className="flex items-center gap-2">
|
|
354
|
+
{service.latency !== undefined && (
|
|
355
|
+
<span className={`text-xs ${themeClasses.textMuted}`}>{service.latency}ms</span>
|
|
356
|
+
)}
|
|
357
|
+
<span className={`text-xs px-2 py-0.5 rounded ${
|
|
358
|
+
service.status === 'healthy' ? 'bg-emerald-500/20 text-emerald-400' :
|
|
359
|
+
service.status === 'degraded' ? 'bg-amber-500/20 text-amber-400' :
|
|
360
|
+
'bg-red-500/20 text-red-400'
|
|
361
|
+
}`}>
|
|
362
|
+
{service.status}
|
|
363
|
+
</span>
|
|
364
|
+
</div>
|
|
365
|
+
</div>
|
|
366
|
+
))}
|
|
367
|
+
</div>
|
|
368
|
+
</div>
|
|
369
|
+
)}
|
|
370
|
+
</div>
|
|
371
|
+
</div>
|
|
372
|
+
|
|
373
|
+
{/* Custom children content */}
|
|
374
|
+
{children}
|
|
375
|
+
</div>
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export default StatsTab;
|