@payez/next-mvp 4.1.8 → 4.1.9
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/auth/better-auth.js +15 -3
- 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/idp-fetch.js +9 -25
- package/dist/pages/login/page.js +28 -28
- package/dist/pages/settings/EnhancedSettingsPage.js +17 -17
- package/package.json +1 -1
- 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 +682 -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 +15 -3
- 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/geolocation.ts +265 -265
- package/src/lib/idp-fetch.ts +10 -26
- package/src/lib/jwt-decode.ts +82 -82
- package/src/lib/roles.ts +177 -177
- 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,728 +1,728 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* =============================================================================
|
|
3
|
-
* VIBE ADMIN ALERT SETTINGS TAB
|
|
4
|
-
* =============================================================================
|
|
5
|
-
*
|
|
6
|
-
* Admin UI for managing email alert preferences, recipients, and thresholds.
|
|
7
|
-
* Supports Smart/Immediate/Hourly/Daily delivery modes.
|
|
8
|
-
*
|
|
9
|
-
* =============================================================================
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
'use client';
|
|
13
|
-
|
|
14
|
-
import React, { useState, useEffect, useCallback } from 'react';
|
|
15
|
-
import {
|
|
16
|
-
Bell,
|
|
17
|
-
Mail,
|
|
18
|
-
Plus,
|
|
19
|
-
X,
|
|
20
|
-
Clock,
|
|
21
|
-
Zap,
|
|
22
|
-
AlertTriangle,
|
|
23
|
-
HardDrive,
|
|
24
|
-
Bot,
|
|
25
|
-
Send,
|
|
26
|
-
History,
|
|
27
|
-
RefreshCw,
|
|
28
|
-
AlertCircle,
|
|
29
|
-
Check,
|
|
30
|
-
ChevronDown,
|
|
31
|
-
Info,
|
|
32
|
-
} from 'lucide-react';
|
|
33
|
-
|
|
34
|
-
// -----------------------------------------------------------------------------
|
|
35
|
-
// TYPES
|
|
36
|
-
// -----------------------------------------------------------------------------
|
|
37
|
-
|
|
38
|
-
export type DeliveryMode = 'smart' | 'immediate' | 'hourly' | 'daily';
|
|
39
|
-
|
|
40
|
-
export interface AlertConfig {
|
|
41
|
-
enabled: boolean;
|
|
42
|
-
threshold?: number;
|
|
43
|
-
threshold_pct?: number;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export interface AlertsConfig {
|
|
47
|
-
error_spike: AlertConfig;
|
|
48
|
-
storage_warning: AlertConfig;
|
|
49
|
-
storage_critical: AlertConfig;
|
|
50
|
-
agent_expiring: AlertConfig;
|
|
51
|
-
agent_expired: AlertConfig;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export interface RateLimit {
|
|
55
|
-
max_per_hour: number;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export interface AlertSettings {
|
|
59
|
-
recipients: string[];
|
|
60
|
-
digest_mode: DeliveryMode;
|
|
61
|
-
alerts: AlertsConfig;
|
|
62
|
-
rate_limit: RateLimit;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export interface AlertHistoryItem {
|
|
66
|
-
id: number;
|
|
67
|
-
type: string;
|
|
68
|
-
sent_at: string;
|
|
69
|
-
subject: string;
|
|
70
|
-
recipients: string[];
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export interface AlertSettingsTabProps {
|
|
74
|
-
isDark?: boolean;
|
|
75
|
-
/** API base path (default: /api/admin/alerts) */
|
|
76
|
-
apiBasePath?: string;
|
|
77
|
-
/** Callback when settings are saved */
|
|
78
|
-
onSave?: (settings: Partial<AlertSettings>) => void;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// -----------------------------------------------------------------------------
|
|
82
|
-
// CONSTANTS
|
|
83
|
-
// -----------------------------------------------------------------------------
|
|
84
|
-
|
|
85
|
-
const DELIVERY_MODES: { value: DeliveryMode; label: string; description: string }[] = [
|
|
86
|
-
{ value: 'smart', label: 'Smart', description: 'Critical alerts immediate, others batched (Recommended)' },
|
|
87
|
-
{ value: 'immediate', label: 'Immediate', description: 'All alerts sent as they occur' },
|
|
88
|
-
{ value: 'hourly', label: 'Hourly', description: 'Batched into hourly digest' },
|
|
89
|
-
{ value: 'daily', label: 'Daily', description: 'Batched into daily digest (8 AM)' },
|
|
90
|
-
];
|
|
91
|
-
|
|
92
|
-
const ALERT_TYPES: {
|
|
93
|
-
id: keyof AlertsConfig;
|
|
94
|
-
label: string;
|
|
95
|
-
description: string;
|
|
96
|
-
icon: React.ElementType;
|
|
97
|
-
hasThreshold: boolean;
|
|
98
|
-
thresholdLabel?: string;
|
|
99
|
-
thresholdUnit?: string;
|
|
100
|
-
alwaysSent?: boolean;
|
|
101
|
-
}[] = [
|
|
102
|
-
{
|
|
103
|
-
id: 'error_spike',
|
|
104
|
-
label: 'Error Spike',
|
|
105
|
-
description: 'Alert when errors exceed threshold',
|
|
106
|
-
icon: AlertTriangle,
|
|
107
|
-
hasThreshold: true,
|
|
108
|
-
thresholdLabel: 'Threshold',
|
|
109
|
-
thresholdUnit: 'errors/hour',
|
|
110
|
-
},
|
|
111
|
-
{
|
|
112
|
-
id: 'storage_warning',
|
|
113
|
-
label: 'Storage Warning',
|
|
114
|
-
description: 'Alert when log storage approaches capacity',
|
|
115
|
-
icon: HardDrive,
|
|
116
|
-
hasThreshold: true,
|
|
117
|
-
thresholdLabel: 'Threshold',
|
|
118
|
-
thresholdUnit: '% capacity',
|
|
119
|
-
},
|
|
120
|
-
{
|
|
121
|
-
id: 'storage_critical',
|
|
122
|
-
label: 'Storage Critical',
|
|
123
|
-
description: 'Alert when storage is nearly full',
|
|
124
|
-
icon: HardDrive,
|
|
125
|
-
hasThreshold: true,
|
|
126
|
-
thresholdLabel: 'Threshold',
|
|
127
|
-
thresholdUnit: '% capacity',
|
|
128
|
-
alwaysSent: true,
|
|
129
|
-
},
|
|
130
|
-
{
|
|
131
|
-
id: 'agent_expiring',
|
|
132
|
-
label: 'Agent Access Expiring',
|
|
133
|
-
description: 'Alert 24 hours before agent access expires',
|
|
134
|
-
icon: Bot,
|
|
135
|
-
hasThreshold: false,
|
|
136
|
-
},
|
|
137
|
-
{
|
|
138
|
-
id: 'agent_expired',
|
|
139
|
-
label: 'Agent Access Expired',
|
|
140
|
-
description: 'Alert when agent access has expired',
|
|
141
|
-
icon: Bot,
|
|
142
|
-
hasThreshold: false,
|
|
143
|
-
alwaysSent: true,
|
|
144
|
-
},
|
|
145
|
-
];
|
|
146
|
-
|
|
147
|
-
const TEST_ALERT_OPTIONS = [
|
|
148
|
-
{ value: 'error_spike', label: 'Error Spike Alert' },
|
|
149
|
-
{ value: 'storage_warning', label: 'Storage Warning Alert' },
|
|
150
|
-
{ value: 'agent_expiring', label: 'Agent Expiring Alert' },
|
|
151
|
-
];
|
|
152
|
-
|
|
153
|
-
const DEFAULT_SETTINGS: AlertSettings = {
|
|
154
|
-
recipients: [],
|
|
155
|
-
digest_mode: 'smart',
|
|
156
|
-
alerts: {
|
|
157
|
-
error_spike: { enabled: true, threshold: 10 },
|
|
158
|
-
storage_warning: { enabled: true, threshold_pct: 80 },
|
|
159
|
-
storage_critical: { enabled: true, threshold_pct: 95 },
|
|
160
|
-
agent_expiring: { enabled: true },
|
|
161
|
-
agent_expired: { enabled: true },
|
|
162
|
-
},
|
|
163
|
-
rate_limit: { max_per_hour: 10 },
|
|
164
|
-
};
|
|
165
|
-
|
|
166
|
-
// -----------------------------------------------------------------------------
|
|
167
|
-
// COMPONENT
|
|
168
|
-
// -----------------------------------------------------------------------------
|
|
169
|
-
|
|
170
|
-
export function AlertSettingsTab({
|
|
171
|
-
isDark = true,
|
|
172
|
-
apiBasePath = '/api/admin/alerts',
|
|
173
|
-
onSave,
|
|
174
|
-
}: AlertSettingsTabProps) {
|
|
175
|
-
const [settings, setSettings] = useState<AlertSettings | null>(null);
|
|
176
|
-
const [loading, setLoading] = useState(true);
|
|
177
|
-
const [saving, setSaving] = useState(false);
|
|
178
|
-
const [sendingTest, setSendingTest] = useState(false);
|
|
179
|
-
const [error, setError] = useState<string | null>(null);
|
|
180
|
-
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
|
181
|
-
const [hasChanges, setHasChanges] = useState(false);
|
|
182
|
-
|
|
183
|
-
// Edited state
|
|
184
|
-
const [editedRecipients, setEditedRecipients] = useState<string[]>([]);
|
|
185
|
-
const [editedMode, setEditedMode] = useState<DeliveryMode>('smart');
|
|
186
|
-
const [editedAlerts, setEditedAlerts] = useState<AlertsConfig>(DEFAULT_SETTINGS.alerts);
|
|
187
|
-
const [editedRateLimit, setEditedRateLimit] = useState<number>(10);
|
|
188
|
-
|
|
189
|
-
// New recipient input
|
|
190
|
-
const [newRecipient, setNewRecipient] = useState('');
|
|
191
|
-
const [recipientError, setRecipientError] = useState<string | null>(null);
|
|
192
|
-
|
|
193
|
-
const themeClasses = {
|
|
194
|
-
bg: isDark ? 'bg-slate-950' : 'bg-gray-50',
|
|
195
|
-
cardBg: isDark ? 'bg-slate-800 border-slate-700' : 'bg-white border-gray-200 shadow-sm',
|
|
196
|
-
textPrimary: isDark ? 'text-white' : 'text-gray-900',
|
|
197
|
-
textSecondary: isDark ? 'text-gray-400' : 'text-gray-600',
|
|
198
|
-
textMuted: isDark ? 'text-gray-500' : 'text-gray-500',
|
|
199
|
-
inputBg: isDark ? 'bg-slate-900 border-slate-600' : 'bg-white border-gray-300',
|
|
200
|
-
hoverBg: isDark ? 'hover:bg-slate-700' : 'hover:bg-gray-100',
|
|
201
|
-
tagBg: isDark ? 'bg-slate-700' : 'bg-gray-100',
|
|
202
|
-
};
|
|
203
|
-
|
|
204
|
-
// Fetch settings
|
|
205
|
-
const fetchSettings = useCallback(async () => {
|
|
206
|
-
setLoading(true);
|
|
207
|
-
setError(null);
|
|
208
|
-
|
|
209
|
-
try {
|
|
210
|
-
const res = await fetch(`${apiBasePath}/settings`);
|
|
211
|
-
if (res.ok) {
|
|
212
|
-
const data = await res.json();
|
|
213
|
-
setSettings(data);
|
|
214
|
-
setEditedRecipients(data.recipients || []);
|
|
215
|
-
setEditedMode(data.digest_mode || 'smart');
|
|
216
|
-
setEditedAlerts(data.alerts || DEFAULT_SETTINGS.alerts);
|
|
217
|
-
setEditedRateLimit(data.rate_limit?.max_per_hour || 10);
|
|
218
|
-
} else if (res.status === 404) {
|
|
219
|
-
// Endpoint not ready - use defaults
|
|
220
|
-
setSettings(DEFAULT_SETTINGS);
|
|
221
|
-
setEditedRecipients([]);
|
|
222
|
-
setEditedMode('smart');
|
|
223
|
-
setEditedAlerts(DEFAULT_SETTINGS.alerts);
|
|
224
|
-
setEditedRateLimit(10);
|
|
225
|
-
} else {
|
|
226
|
-
throw new Error('Failed to fetch settings');
|
|
227
|
-
}
|
|
228
|
-
} catch (err: any) {
|
|
229
|
-
setError(err.message);
|
|
230
|
-
} finally {
|
|
231
|
-
setLoading(false);
|
|
232
|
-
}
|
|
233
|
-
}, [apiBasePath]);
|
|
234
|
-
|
|
235
|
-
useEffect(() => {
|
|
236
|
-
fetchSettings();
|
|
237
|
-
}, [fetchSettings]);
|
|
238
|
-
|
|
239
|
-
// Check for changes
|
|
240
|
-
useEffect(() => {
|
|
241
|
-
if (!settings) {
|
|
242
|
-
setHasChanges(false);
|
|
243
|
-
return;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
const recipientsChanged = JSON.stringify(settings.recipients) !== JSON.stringify(editedRecipients);
|
|
247
|
-
const modeChanged = settings.digest_mode !== editedMode;
|
|
248
|
-
const alertsChanged = JSON.stringify(settings.alerts) !== JSON.stringify(editedAlerts);
|
|
249
|
-
const rateLimitChanged = settings.rate_limit.max_per_hour !== editedRateLimit;
|
|
250
|
-
|
|
251
|
-
setHasChanges(recipientsChanged || modeChanged || alertsChanged || rateLimitChanged);
|
|
252
|
-
}, [settings, editedRecipients, editedMode, editedAlerts, editedRateLimit]);
|
|
253
|
-
|
|
254
|
-
// Add recipient
|
|
255
|
-
const addRecipient = () => {
|
|
256
|
-
const email = newRecipient.trim().toLowerCase();
|
|
257
|
-
setRecipientError(null);
|
|
258
|
-
|
|
259
|
-
if (!email) return;
|
|
260
|
-
|
|
261
|
-
// Basic email validation
|
|
262
|
-
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
263
|
-
if (!emailRegex.test(email)) {
|
|
264
|
-
setRecipientError('Please enter a valid email address');
|
|
265
|
-
return;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
if (editedRecipients.includes(email)) {
|
|
269
|
-
setRecipientError('This email is already in the list');
|
|
270
|
-
return;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
setEditedRecipients([...editedRecipients, email]);
|
|
274
|
-
setNewRecipient('');
|
|
275
|
-
};
|
|
276
|
-
|
|
277
|
-
// Remove recipient
|
|
278
|
-
const removeRecipient = (email: string) => {
|
|
279
|
-
setEditedRecipients(editedRecipients.filter(r => r !== email));
|
|
280
|
-
};
|
|
281
|
-
|
|
282
|
-
// Update alert config
|
|
283
|
-
const updateAlertConfig = (alertId: keyof AlertsConfig, updates: Partial<AlertConfig>) => {
|
|
284
|
-
setEditedAlerts(prev => ({
|
|
285
|
-
...prev,
|
|
286
|
-
[alertId]: { ...prev[alertId], ...updates },
|
|
287
|
-
}));
|
|
288
|
-
};
|
|
289
|
-
|
|
290
|
-
// Save settings
|
|
291
|
-
const saveSettings = async () => {
|
|
292
|
-
if (!hasChanges) return;
|
|
293
|
-
|
|
294
|
-
setSaving(true);
|
|
295
|
-
setError(null);
|
|
296
|
-
|
|
297
|
-
try {
|
|
298
|
-
const payload: AlertSettings = {
|
|
299
|
-
recipients: editedRecipients,
|
|
300
|
-
digest_mode: editedMode,
|
|
301
|
-
alerts: editedAlerts,
|
|
302
|
-
rate_limit: { max_per_hour: editedRateLimit },
|
|
303
|
-
};
|
|
304
|
-
|
|
305
|
-
const res = await fetch(`${apiBasePath}/settings`, {
|
|
306
|
-
method: 'PUT',
|
|
307
|
-
headers: { 'Content-Type': 'application/json' },
|
|
308
|
-
body: JSON.stringify(payload),
|
|
309
|
-
});
|
|
310
|
-
|
|
311
|
-
if (res.ok) {
|
|
312
|
-
setSuccessMessage('Settings saved successfully');
|
|
313
|
-
setTimeout(() => setSuccessMessage(null), 3000);
|
|
314
|
-
onSave?.(payload);
|
|
315
|
-
fetchSettings();
|
|
316
|
-
} else if (res.status === 404) {
|
|
317
|
-
// Endpoint not ready - simulate success
|
|
318
|
-
setSettings(payload);
|
|
319
|
-
setSuccessMessage('Settings saved (demo mode)');
|
|
320
|
-
setTimeout(() => setSuccessMessage(null), 3000);
|
|
321
|
-
} else {
|
|
322
|
-
throw new Error('Failed to save settings');
|
|
323
|
-
}
|
|
324
|
-
} catch (err: any) {
|
|
325
|
-
setError(err.message);
|
|
326
|
-
} finally {
|
|
327
|
-
setSaving(false);
|
|
328
|
-
}
|
|
329
|
-
};
|
|
330
|
-
|
|
331
|
-
// Send test alert
|
|
332
|
-
const sendTestAlert = async (alertType: string) => {
|
|
333
|
-
setSendingTest(true);
|
|
334
|
-
setError(null);
|
|
335
|
-
|
|
336
|
-
try {
|
|
337
|
-
const res = await fetch(`${apiBasePath}/test`, {
|
|
338
|
-
method: 'POST',
|
|
339
|
-
headers: { 'Content-Type': 'application/json' },
|
|
340
|
-
body: JSON.stringify({
|
|
341
|
-
type: alertType,
|
|
342
|
-
recipient: editedRecipients[0] || 'test@example.com',
|
|
343
|
-
}),
|
|
344
|
-
});
|
|
345
|
-
|
|
346
|
-
if (res.ok) {
|
|
347
|
-
setSuccessMessage(`Test ${alertType.replace('_', ' ')} alert sent!`);
|
|
348
|
-
setTimeout(() => setSuccessMessage(null), 3000);
|
|
349
|
-
} else if (res.status === 404) {
|
|
350
|
-
setSuccessMessage('Test alert sent (demo mode)');
|
|
351
|
-
setTimeout(() => setSuccessMessage(null), 3000);
|
|
352
|
-
} else {
|
|
353
|
-
throw new Error('Failed to send test alert');
|
|
354
|
-
}
|
|
355
|
-
} catch (err: any) {
|
|
356
|
-
setError(err.message);
|
|
357
|
-
} finally {
|
|
358
|
-
setSendingTest(false);
|
|
359
|
-
}
|
|
360
|
-
};
|
|
361
|
-
|
|
362
|
-
if (loading) {
|
|
363
|
-
return (
|
|
364
|
-
<div className="flex items-center justify-center py-12">
|
|
365
|
-
<RefreshCw className="w-8 h-8 animate-spin text-indigo-500" />
|
|
366
|
-
</div>
|
|
367
|
-
);
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
return (
|
|
371
|
-
<div className="space-y-6">
|
|
372
|
-
{/* Error/Success Messages */}
|
|
373
|
-
{error && (
|
|
374
|
-
<div className={`flex items-center gap-3 p-4 rounded-lg ${isDark ? 'bg-red-900/30 border-red-700' : 'bg-red-50 border-red-300'} border`}>
|
|
375
|
-
<AlertCircle className={isDark ? 'text-red-400' : 'text-red-500'} size={20} />
|
|
376
|
-
<span className={isDark ? 'text-red-300' : 'text-red-700'}>{error}</span>
|
|
377
|
-
</div>
|
|
378
|
-
)}
|
|
379
|
-
|
|
380
|
-
{successMessage && (
|
|
381
|
-
<div className={`flex items-center gap-3 p-4 rounded-lg ${isDark ? 'bg-green-900/30 border-green-700' : 'bg-green-50 border-green-300'} border`}>
|
|
382
|
-
<Check className={isDark ? 'text-green-400' : 'text-green-500'} size={20} />
|
|
383
|
-
<span className={isDark ? 'text-green-300' : 'text-green-700'}>{successMessage}</span>
|
|
384
|
-
</div>
|
|
385
|
-
)}
|
|
386
|
-
|
|
387
|
-
{/* Section 1: Recipients */}
|
|
388
|
-
<div className={`${themeClasses.cardBg} border rounded-xl overflow-hidden`}>
|
|
389
|
-
<div className={`px-5 py-4 border-b ${isDark ? 'border-slate-700' : 'border-gray-200'}`}>
|
|
390
|
-
<div className="flex items-center gap-2">
|
|
391
|
-
<Mail className={isDark ? 'text-violet-400' : 'text-violet-500'} size={20} />
|
|
392
|
-
<h3 className={`font-semibold ${themeClasses.textPrimary}`}>Alert Recipients</h3>
|
|
393
|
-
</div>
|
|
394
|
-
<p className={`text-sm ${themeClasses.textMuted} mt-1`}>
|
|
395
|
-
Email addresses that receive alert notifications
|
|
396
|
-
</p>
|
|
397
|
-
</div>
|
|
398
|
-
<div className="p-5 space-y-4">
|
|
399
|
-
{/* Recipient list */}
|
|
400
|
-
<div className="flex flex-wrap gap-2">
|
|
401
|
-
{editedRecipients.map((email) => (
|
|
402
|
-
<div
|
|
403
|
-
key={email}
|
|
404
|
-
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg ${themeClasses.tagBg}`}
|
|
405
|
-
>
|
|
406
|
-
<span className={`text-sm ${themeClasses.textPrimary}`}>{email}</span>
|
|
407
|
-
<button
|
|
408
|
-
onClick={() => removeRecipient(email)}
|
|
409
|
-
className={`p-0.5 rounded ${isDark ? 'hover:bg-slate-600' : 'hover:bg-gray-300'} transition-colors`}
|
|
410
|
-
>
|
|
411
|
-
<X size={14} className={themeClasses.textMuted} />
|
|
412
|
-
</button>
|
|
413
|
-
</div>
|
|
414
|
-
))}
|
|
415
|
-
{editedRecipients.length === 0 && (
|
|
416
|
-
<span className={`text-sm ${themeClasses.textMuted}`}>No recipients configured</span>
|
|
417
|
-
)}
|
|
418
|
-
</div>
|
|
419
|
-
|
|
420
|
-
{/* Add recipient input */}
|
|
421
|
-
<div className="flex gap-2">
|
|
422
|
-
<div className="flex-1">
|
|
423
|
-
<input
|
|
424
|
-
type="email"
|
|
425
|
-
value={newRecipient}
|
|
426
|
-
onChange={(e) => {
|
|
427
|
-
setNewRecipient(e.target.value);
|
|
428
|
-
setRecipientError(null);
|
|
429
|
-
}}
|
|
430
|
-
onKeyDown={(e) => {
|
|
431
|
-
if (e.key === 'Enter') {
|
|
432
|
-
e.preventDefault();
|
|
433
|
-
addRecipient();
|
|
434
|
-
}
|
|
435
|
-
}}
|
|
436
|
-
placeholder="Enter email address"
|
|
437
|
-
className={`
|
|
438
|
-
w-full px-3 py-2 rounded-lg text-sm
|
|
439
|
-
${themeClasses.inputBg} ${themeClasses.textPrimary} border
|
|
440
|
-
focus:outline-none focus:ring-2 focus:ring-indigo-500
|
|
441
|
-
${recipientError ? (isDark ? 'border-red-500' : 'border-red-400') : ''}
|
|
442
|
-
`}
|
|
443
|
-
/>
|
|
444
|
-
{recipientError && (
|
|
445
|
-
<p className={`text-xs mt-1 ${isDark ? 'text-red-400' : 'text-red-500'}`}>
|
|
446
|
-
{recipientError}
|
|
447
|
-
</p>
|
|
448
|
-
)}
|
|
449
|
-
</div>
|
|
450
|
-
<button
|
|
451
|
-
onClick={addRecipient}
|
|
452
|
-
className={`
|
|
453
|
-
px-4 py-2 rounded-lg font-medium text-sm transition-colors
|
|
454
|
-
${isDark ? 'bg-indigo-600 hover:bg-indigo-700' : 'bg-indigo-500 hover:bg-indigo-600'}
|
|
455
|
-
text-white flex items-center gap-2
|
|
456
|
-
`}
|
|
457
|
-
>
|
|
458
|
-
<Plus size={16} />
|
|
459
|
-
Add
|
|
460
|
-
</button>
|
|
461
|
-
</div>
|
|
462
|
-
</div>
|
|
463
|
-
</div>
|
|
464
|
-
|
|
465
|
-
{/* Section 2: Delivery Mode */}
|
|
466
|
-
<div className={`${themeClasses.cardBg} border rounded-xl overflow-hidden`}>
|
|
467
|
-
<div className={`px-5 py-4 border-b ${isDark ? 'border-slate-700' : 'border-gray-200'}`}>
|
|
468
|
-
<div className="flex items-center gap-2">
|
|
469
|
-
<Clock className={isDark ? 'text-cyan-400' : 'text-cyan-500'} size={20} />
|
|
470
|
-
<h3 className={`font-semibold ${themeClasses.textPrimary}`}>Delivery Mode</h3>
|
|
471
|
-
</div>
|
|
472
|
-
<p className={`text-sm ${themeClasses.textMuted} mt-1`}>
|
|
473
|
-
How alerts are delivered to recipients
|
|
474
|
-
</p>
|
|
475
|
-
</div>
|
|
476
|
-
<div className="p-5">
|
|
477
|
-
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
|
478
|
-
{DELIVERY_MODES.map((mode) => {
|
|
479
|
-
const isSelected = editedMode === mode.value;
|
|
480
|
-
return (
|
|
481
|
-
<button
|
|
482
|
-
key={mode.value}
|
|
483
|
-
onClick={() => setEditedMode(mode.value)}
|
|
484
|
-
className={`
|
|
485
|
-
p-4 rounded-lg border-2 text-left transition-all
|
|
486
|
-
${isSelected
|
|
487
|
-
? 'border-indigo-500 ' + (isDark ? 'bg-indigo-500/10' : 'bg-indigo-50')
|
|
488
|
-
: (isDark ? 'border-slate-700 hover:border-slate-600' : 'border-gray-200 hover:border-gray-300')
|
|
489
|
-
}
|
|
490
|
-
`}
|
|
491
|
-
>
|
|
492
|
-
<div className="flex items-center gap-2 mb-1">
|
|
493
|
-
<div className={`w-3 h-3 rounded-full border-2 flex items-center justify-center ${
|
|
494
|
-
isSelected ? 'border-indigo-500' : (isDark ? 'border-slate-500' : 'border-gray-400')
|
|
495
|
-
}`}>
|
|
496
|
-
{isSelected && <div className="w-1.5 h-1.5 rounded-full bg-indigo-500" />}
|
|
497
|
-
</div>
|
|
498
|
-
<span className={`font-medium ${themeClasses.textPrimary}`}>{mode.label}</span>
|
|
499
|
-
</div>
|
|
500
|
-
<p className={`text-xs ${themeClasses.textMuted} ml-5`}>{mode.description}</p>
|
|
501
|
-
</button>
|
|
502
|
-
);
|
|
503
|
-
})}
|
|
504
|
-
</div>
|
|
505
|
-
</div>
|
|
506
|
-
</div>
|
|
507
|
-
|
|
508
|
-
{/* Section 3: Alert Configuration */}
|
|
509
|
-
<div className={`${themeClasses.cardBg} border rounded-xl overflow-hidden`}>
|
|
510
|
-
<div className={`px-5 py-4 border-b ${isDark ? 'border-slate-700' : 'border-gray-200'}`}>
|
|
511
|
-
<div className="flex items-center gap-2">
|
|
512
|
-
<Bell className={isDark ? 'text-amber-400' : 'text-amber-500'} size={20} />
|
|
513
|
-
<h3 className={`font-semibold ${themeClasses.textPrimary}`}>Alert Types</h3>
|
|
514
|
-
</div>
|
|
515
|
-
<p className={`text-sm ${themeClasses.textMuted} mt-1`}>
|
|
516
|
-
Configure which alerts to receive and their thresholds
|
|
517
|
-
</p>
|
|
518
|
-
</div>
|
|
519
|
-
<div className="divide-y divide-slate-700/50">
|
|
520
|
-
{ALERT_TYPES.map((alertType) => {
|
|
521
|
-
const config = editedAlerts[alertType.id];
|
|
522
|
-
const Icon = alertType.icon;
|
|
523
|
-
|
|
524
|
-
return (
|
|
525
|
-
<div key={alertType.id} className="p-5">
|
|
526
|
-
<div className="flex items-start justify-between gap-4">
|
|
527
|
-
<div className="flex items-start gap-3">
|
|
528
|
-
<div className={`p-2 rounded-lg ${isDark ? 'bg-slate-700' : 'bg-gray-100'}`}>
|
|
529
|
-
<Icon size={18} className={themeClasses.textSecondary} />
|
|
530
|
-
</div>
|
|
531
|
-
<div>
|
|
532
|
-
<div className="flex items-center gap-2">
|
|
533
|
-
<span className={`font-medium ${themeClasses.textPrimary}`}>{alertType.label}</span>
|
|
534
|
-
{alertType.alwaysSent && (
|
|
535
|
-
<span className={`px-2 py-0.5 text-xs rounded ${isDark ? 'bg-red-500/20 text-red-400' : 'bg-red-100 text-red-600'}`}>
|
|
536
|
-
Always sent
|
|
537
|
-
</span>
|
|
538
|
-
)}
|
|
539
|
-
</div>
|
|
540
|
-
<p className={`text-sm ${themeClasses.textMuted}`}>{alertType.description}</p>
|
|
541
|
-
</div>
|
|
542
|
-
</div>
|
|
543
|
-
|
|
544
|
-
<div className="flex items-center gap-4">
|
|
545
|
-
{/* Threshold input */}
|
|
546
|
-
{alertType.hasThreshold && (
|
|
547
|
-
<div className="flex items-center gap-2">
|
|
548
|
-
<input
|
|
549
|
-
type="number"
|
|
550
|
-
min="1"
|
|
551
|
-
max="100"
|
|
552
|
-
value={config.threshold ?? config.threshold_pct ?? 10}
|
|
553
|
-
onChange={(e) => {
|
|
554
|
-
const val = parseInt(e.target.value) || 10;
|
|
555
|
-
if (alertType.thresholdUnit?.includes('%')) {
|
|
556
|
-
updateAlertConfig(alertType.id, { threshold_pct: val });
|
|
557
|
-
} else {
|
|
558
|
-
updateAlertConfig(alertType.id, { threshold: val });
|
|
559
|
-
}
|
|
560
|
-
}}
|
|
561
|
-
disabled={!config.enabled && !alertType.alwaysSent}
|
|
562
|
-
className={`
|
|
563
|
-
w-16 px-2 py-1.5 rounded text-sm text-center
|
|
564
|
-
${themeClasses.inputBg} ${themeClasses.textPrimary} border
|
|
565
|
-
focus:outline-none focus:ring-2 focus:ring-indigo-500
|
|
566
|
-
disabled:opacity-50
|
|
567
|
-
`}
|
|
568
|
-
/>
|
|
569
|
-
<span className={`text-xs ${themeClasses.textMuted} whitespace-nowrap`}>
|
|
570
|
-
{alertType.thresholdUnit}
|
|
571
|
-
</span>
|
|
572
|
-
</div>
|
|
573
|
-
)}
|
|
574
|
-
|
|
575
|
-
{/* Toggle */}
|
|
576
|
-
{!alertType.alwaysSent && (
|
|
577
|
-
<button
|
|
578
|
-
onClick={() => updateAlertConfig(alertType.id, { enabled: !config.enabled })}
|
|
579
|
-
className={`
|
|
580
|
-
relative w-11 h-6 rounded-full transition-colors
|
|
581
|
-
${config.enabled
|
|
582
|
-
? (isDark ? 'bg-indigo-600' : 'bg-indigo-500')
|
|
583
|
-
: (isDark ? 'bg-slate-600' : 'bg-gray-300')
|
|
584
|
-
}
|
|
585
|
-
`}
|
|
586
|
-
>
|
|
587
|
-
<span
|
|
588
|
-
className={`
|
|
589
|
-
absolute top-1 left-1 w-4 h-4 rounded-full bg-white transition-transform
|
|
590
|
-
${config.enabled ? 'translate-x-5' : ''}
|
|
591
|
-
`}
|
|
592
|
-
/>
|
|
593
|
-
</button>
|
|
594
|
-
)}
|
|
595
|
-
</div>
|
|
596
|
-
</div>
|
|
597
|
-
</div>
|
|
598
|
-
);
|
|
599
|
-
})}
|
|
600
|
-
</div>
|
|
601
|
-
</div>
|
|
602
|
-
|
|
603
|
-
{/* Section 4: Rate Limiting */}
|
|
604
|
-
<div className={`${themeClasses.cardBg} border rounded-xl overflow-hidden`}>
|
|
605
|
-
<div className={`px-5 py-4 border-b ${isDark ? 'border-slate-700' : 'border-gray-200'}`}>
|
|
606
|
-
<div className="flex items-center gap-2">
|
|
607
|
-
<Zap className={isDark ? 'text-emerald-400' : 'text-emerald-500'} size={20} />
|
|
608
|
-
<h3 className={`font-semibold ${themeClasses.textPrimary}`}>Rate Limiting</h3>
|
|
609
|
-
</div>
|
|
610
|
-
</div>
|
|
611
|
-
<div className="p-5">
|
|
612
|
-
<div className="flex items-center gap-4">
|
|
613
|
-
<label className={`text-sm ${themeClasses.textSecondary}`}>Maximum alerts:</label>
|
|
614
|
-
<input
|
|
615
|
-
type="number"
|
|
616
|
-
min="1"
|
|
617
|
-
max="100"
|
|
618
|
-
value={editedRateLimit}
|
|
619
|
-
onChange={(e) => setEditedRateLimit(parseInt(e.target.value) || 10)}
|
|
620
|
-
className={`
|
|
621
|
-
w-20 px-3 py-2 rounded-lg text-sm
|
|
622
|
-
${themeClasses.inputBg} ${themeClasses.textPrimary} border
|
|
623
|
-
focus:outline-none focus:ring-2 focus:ring-indigo-500
|
|
624
|
-
`}
|
|
625
|
-
/>
|
|
626
|
-
<span className={`text-sm ${themeClasses.textMuted}`}>per hour</span>
|
|
627
|
-
</div>
|
|
628
|
-
<div className={`flex items-start gap-2 mt-3 text-xs ${themeClasses.textMuted}`}>
|
|
629
|
-
<Info size={14} className="flex-shrink-0 mt-0.5" />
|
|
630
|
-
<span>Critical alerts (Storage Critical, Agent Expired) are always sent regardless of this limit.</span>
|
|
631
|
-
</div>
|
|
632
|
-
</div>
|
|
633
|
-
</div>
|
|
634
|
-
|
|
635
|
-
{/* Section 5: Test & History */}
|
|
636
|
-
<div className={`${themeClasses.cardBg} border rounded-xl overflow-hidden`}>
|
|
637
|
-
<div className={`px-5 py-4 border-b ${isDark ? 'border-slate-700' : 'border-gray-200'}`}>
|
|
638
|
-
<div className="flex items-center gap-2">
|
|
639
|
-
<Send className={isDark ? 'text-blue-400' : 'text-blue-500'} size={20} />
|
|
640
|
-
<h3 className={`font-semibold ${themeClasses.textPrimary}`}>Test & History</h3>
|
|
641
|
-
</div>
|
|
642
|
-
</div>
|
|
643
|
-
<div className="p-5">
|
|
644
|
-
<div className="flex flex-wrap gap-3">
|
|
645
|
-
{/* Send Test Alert dropdown */}
|
|
646
|
-
<div className="relative">
|
|
647
|
-
<select
|
|
648
|
-
disabled={sendingTest || editedRecipients.length === 0}
|
|
649
|
-
onChange={(e) => {
|
|
650
|
-
if (e.target.value) {
|
|
651
|
-
sendTestAlert(e.target.value);
|
|
652
|
-
e.target.value = '';
|
|
653
|
-
}
|
|
654
|
-
}}
|
|
655
|
-
className={`
|
|
656
|
-
px-4 py-2.5 pr-10 rounded-lg text-sm font-medium cursor-pointer appearance-none
|
|
657
|
-
${isDark ? 'bg-blue-900/30 text-blue-300 border-blue-700 hover:bg-blue-900/50' : 'bg-blue-50 text-blue-700 border-blue-300 hover:bg-blue-100'}
|
|
658
|
-
border focus:outline-none focus:ring-2 focus:ring-blue-500
|
|
659
|
-
disabled:opacity-50 disabled:cursor-not-allowed
|
|
660
|
-
`}
|
|
661
|
-
>
|
|
662
|
-
<option value="">Send Test Alert...</option>
|
|
663
|
-
{TEST_ALERT_OPTIONS.map((opt) => (
|
|
664
|
-
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
|
665
|
-
))}
|
|
666
|
-
</select>
|
|
667
|
-
<ChevronDown className={`absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 pointer-events-none ${isDark ? 'text-blue-400' : 'text-blue-500'}`} />
|
|
668
|
-
</div>
|
|
669
|
-
|
|
670
|
-
{/* View History button */}
|
|
671
|
-
<button
|
|
672
|
-
onClick={() => {
|
|
673
|
-
// TODO: Open history modal or navigate to history page
|
|
674
|
-
window.open(`${apiBasePath}/history`, '_blank');
|
|
675
|
-
}}
|
|
676
|
-
className={`
|
|
677
|
-
px-4 py-2.5 rounded-lg text-sm font-medium flex items-center gap-2
|
|
678
|
-
${isDark ? 'bg-slate-700 text-white hover:bg-slate-600' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}
|
|
679
|
-
border ${isDark ? 'border-slate-600' : 'border-gray-300'}
|
|
680
|
-
`}
|
|
681
|
-
>
|
|
682
|
-
<History size={16} />
|
|
683
|
-
View Alert History
|
|
684
|
-
</button>
|
|
685
|
-
</div>
|
|
686
|
-
|
|
687
|
-
{editedRecipients.length === 0 && (
|
|
688
|
-
<p className={`text-xs mt-3 ${themeClasses.textMuted}`}>
|
|
689
|
-
Add at least one recipient to send test alerts.
|
|
690
|
-
</p>
|
|
691
|
-
)}
|
|
692
|
-
</div>
|
|
693
|
-
</div>
|
|
694
|
-
|
|
695
|
-
{/* Save Button */}
|
|
696
|
-
<div className="flex items-center justify-end gap-4">
|
|
697
|
-
{hasChanges && (
|
|
698
|
-
<span className={`text-sm ${themeClasses.textMuted}`}>
|
|
699
|
-
You have unsaved changes
|
|
700
|
-
</span>
|
|
701
|
-
)}
|
|
702
|
-
<button
|
|
703
|
-
onClick={saveSettings}
|
|
704
|
-
disabled={!hasChanges || saving}
|
|
705
|
-
className={`
|
|
706
|
-
px-6 py-2.5 rounded-lg font-medium text-sm transition-all
|
|
707
|
-
${hasChanges
|
|
708
|
-
? 'bg-indigo-600 text-white hover:bg-indigo-700'
|
|
709
|
-
: (isDark ? 'bg-slate-700 text-slate-400' : 'bg-gray-200 text-gray-400')
|
|
710
|
-
}
|
|
711
|
-
disabled:cursor-not-allowed
|
|
712
|
-
`}
|
|
713
|
-
>
|
|
714
|
-
{saving ? (
|
|
715
|
-
<span className="flex items-center gap-2">
|
|
716
|
-
<RefreshCw className="w-4 h-4 animate-spin" />
|
|
717
|
-
Saving...
|
|
718
|
-
</span>
|
|
719
|
-
) : (
|
|
720
|
-
'Save Settings'
|
|
721
|
-
)}
|
|
722
|
-
</button>
|
|
723
|
-
</div>
|
|
724
|
-
</div>
|
|
725
|
-
);
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
export default AlertSettingsTab;
|
|
1
|
+
/**
|
|
2
|
+
* =============================================================================
|
|
3
|
+
* VIBE ADMIN ALERT SETTINGS TAB
|
|
4
|
+
* =============================================================================
|
|
5
|
+
*
|
|
6
|
+
* Admin UI for managing email alert preferences, recipients, and thresholds.
|
|
7
|
+
* Supports Smart/Immediate/Hourly/Daily delivery modes.
|
|
8
|
+
*
|
|
9
|
+
* =============================================================================
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
'use client';
|
|
13
|
+
|
|
14
|
+
import React, { useState, useEffect, useCallback } from 'react';
|
|
15
|
+
import {
|
|
16
|
+
Bell,
|
|
17
|
+
Mail,
|
|
18
|
+
Plus,
|
|
19
|
+
X,
|
|
20
|
+
Clock,
|
|
21
|
+
Zap,
|
|
22
|
+
AlertTriangle,
|
|
23
|
+
HardDrive,
|
|
24
|
+
Bot,
|
|
25
|
+
Send,
|
|
26
|
+
History,
|
|
27
|
+
RefreshCw,
|
|
28
|
+
AlertCircle,
|
|
29
|
+
Check,
|
|
30
|
+
ChevronDown,
|
|
31
|
+
Info,
|
|
32
|
+
} from 'lucide-react';
|
|
33
|
+
|
|
34
|
+
// -----------------------------------------------------------------------------
|
|
35
|
+
// TYPES
|
|
36
|
+
// -----------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
export type DeliveryMode = 'smart' | 'immediate' | 'hourly' | 'daily';
|
|
39
|
+
|
|
40
|
+
export interface AlertConfig {
|
|
41
|
+
enabled: boolean;
|
|
42
|
+
threshold?: number;
|
|
43
|
+
threshold_pct?: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface AlertsConfig {
|
|
47
|
+
error_spike: AlertConfig;
|
|
48
|
+
storage_warning: AlertConfig;
|
|
49
|
+
storage_critical: AlertConfig;
|
|
50
|
+
agent_expiring: AlertConfig;
|
|
51
|
+
agent_expired: AlertConfig;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface RateLimit {
|
|
55
|
+
max_per_hour: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface AlertSettings {
|
|
59
|
+
recipients: string[];
|
|
60
|
+
digest_mode: DeliveryMode;
|
|
61
|
+
alerts: AlertsConfig;
|
|
62
|
+
rate_limit: RateLimit;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface AlertHistoryItem {
|
|
66
|
+
id: number;
|
|
67
|
+
type: string;
|
|
68
|
+
sent_at: string;
|
|
69
|
+
subject: string;
|
|
70
|
+
recipients: string[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface AlertSettingsTabProps {
|
|
74
|
+
isDark?: boolean;
|
|
75
|
+
/** API base path (default: /api/admin/alerts) */
|
|
76
|
+
apiBasePath?: string;
|
|
77
|
+
/** Callback when settings are saved */
|
|
78
|
+
onSave?: (settings: Partial<AlertSettings>) => void;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// -----------------------------------------------------------------------------
|
|
82
|
+
// CONSTANTS
|
|
83
|
+
// -----------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
const DELIVERY_MODES: { value: DeliveryMode; label: string; description: string }[] = [
|
|
86
|
+
{ value: 'smart', label: 'Smart', description: 'Critical alerts immediate, others batched (Recommended)' },
|
|
87
|
+
{ value: 'immediate', label: 'Immediate', description: 'All alerts sent as they occur' },
|
|
88
|
+
{ value: 'hourly', label: 'Hourly', description: 'Batched into hourly digest' },
|
|
89
|
+
{ value: 'daily', label: 'Daily', description: 'Batched into daily digest (8 AM)' },
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
const ALERT_TYPES: {
|
|
93
|
+
id: keyof AlertsConfig;
|
|
94
|
+
label: string;
|
|
95
|
+
description: string;
|
|
96
|
+
icon: React.ElementType;
|
|
97
|
+
hasThreshold: boolean;
|
|
98
|
+
thresholdLabel?: string;
|
|
99
|
+
thresholdUnit?: string;
|
|
100
|
+
alwaysSent?: boolean;
|
|
101
|
+
}[] = [
|
|
102
|
+
{
|
|
103
|
+
id: 'error_spike',
|
|
104
|
+
label: 'Error Spike',
|
|
105
|
+
description: 'Alert when errors exceed threshold',
|
|
106
|
+
icon: AlertTriangle,
|
|
107
|
+
hasThreshold: true,
|
|
108
|
+
thresholdLabel: 'Threshold',
|
|
109
|
+
thresholdUnit: 'errors/hour',
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
id: 'storage_warning',
|
|
113
|
+
label: 'Storage Warning',
|
|
114
|
+
description: 'Alert when log storage approaches capacity',
|
|
115
|
+
icon: HardDrive,
|
|
116
|
+
hasThreshold: true,
|
|
117
|
+
thresholdLabel: 'Threshold',
|
|
118
|
+
thresholdUnit: '% capacity',
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
id: 'storage_critical',
|
|
122
|
+
label: 'Storage Critical',
|
|
123
|
+
description: 'Alert when storage is nearly full',
|
|
124
|
+
icon: HardDrive,
|
|
125
|
+
hasThreshold: true,
|
|
126
|
+
thresholdLabel: 'Threshold',
|
|
127
|
+
thresholdUnit: '% capacity',
|
|
128
|
+
alwaysSent: true,
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
id: 'agent_expiring',
|
|
132
|
+
label: 'Agent Access Expiring',
|
|
133
|
+
description: 'Alert 24 hours before agent access expires',
|
|
134
|
+
icon: Bot,
|
|
135
|
+
hasThreshold: false,
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
id: 'agent_expired',
|
|
139
|
+
label: 'Agent Access Expired',
|
|
140
|
+
description: 'Alert when agent access has expired',
|
|
141
|
+
icon: Bot,
|
|
142
|
+
hasThreshold: false,
|
|
143
|
+
alwaysSent: true,
|
|
144
|
+
},
|
|
145
|
+
];
|
|
146
|
+
|
|
147
|
+
const TEST_ALERT_OPTIONS = [
|
|
148
|
+
{ value: 'error_spike', label: 'Error Spike Alert' },
|
|
149
|
+
{ value: 'storage_warning', label: 'Storage Warning Alert' },
|
|
150
|
+
{ value: 'agent_expiring', label: 'Agent Expiring Alert' },
|
|
151
|
+
];
|
|
152
|
+
|
|
153
|
+
const DEFAULT_SETTINGS: AlertSettings = {
|
|
154
|
+
recipients: [],
|
|
155
|
+
digest_mode: 'smart',
|
|
156
|
+
alerts: {
|
|
157
|
+
error_spike: { enabled: true, threshold: 10 },
|
|
158
|
+
storage_warning: { enabled: true, threshold_pct: 80 },
|
|
159
|
+
storage_critical: { enabled: true, threshold_pct: 95 },
|
|
160
|
+
agent_expiring: { enabled: true },
|
|
161
|
+
agent_expired: { enabled: true },
|
|
162
|
+
},
|
|
163
|
+
rate_limit: { max_per_hour: 10 },
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
// -----------------------------------------------------------------------------
|
|
167
|
+
// COMPONENT
|
|
168
|
+
// -----------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
export function AlertSettingsTab({
|
|
171
|
+
isDark = true,
|
|
172
|
+
apiBasePath = '/api/admin/alerts',
|
|
173
|
+
onSave,
|
|
174
|
+
}: AlertSettingsTabProps) {
|
|
175
|
+
const [settings, setSettings] = useState<AlertSettings | null>(null);
|
|
176
|
+
const [loading, setLoading] = useState(true);
|
|
177
|
+
const [saving, setSaving] = useState(false);
|
|
178
|
+
const [sendingTest, setSendingTest] = useState(false);
|
|
179
|
+
const [error, setError] = useState<string | null>(null);
|
|
180
|
+
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
|
181
|
+
const [hasChanges, setHasChanges] = useState(false);
|
|
182
|
+
|
|
183
|
+
// Edited state
|
|
184
|
+
const [editedRecipients, setEditedRecipients] = useState<string[]>([]);
|
|
185
|
+
const [editedMode, setEditedMode] = useState<DeliveryMode>('smart');
|
|
186
|
+
const [editedAlerts, setEditedAlerts] = useState<AlertsConfig>(DEFAULT_SETTINGS.alerts);
|
|
187
|
+
const [editedRateLimit, setEditedRateLimit] = useState<number>(10);
|
|
188
|
+
|
|
189
|
+
// New recipient input
|
|
190
|
+
const [newRecipient, setNewRecipient] = useState('');
|
|
191
|
+
const [recipientError, setRecipientError] = useState<string | null>(null);
|
|
192
|
+
|
|
193
|
+
const themeClasses = {
|
|
194
|
+
bg: isDark ? 'bg-slate-950' : 'bg-gray-50',
|
|
195
|
+
cardBg: isDark ? 'bg-slate-800 border-slate-700' : 'bg-white border-gray-200 shadow-sm',
|
|
196
|
+
textPrimary: isDark ? 'text-white' : 'text-gray-900',
|
|
197
|
+
textSecondary: isDark ? 'text-gray-400' : 'text-gray-600',
|
|
198
|
+
textMuted: isDark ? 'text-gray-500' : 'text-gray-500',
|
|
199
|
+
inputBg: isDark ? 'bg-slate-900 border-slate-600' : 'bg-white border-gray-300',
|
|
200
|
+
hoverBg: isDark ? 'hover:bg-slate-700' : 'hover:bg-gray-100',
|
|
201
|
+
tagBg: isDark ? 'bg-slate-700' : 'bg-gray-100',
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
// Fetch settings
|
|
205
|
+
const fetchSettings = useCallback(async () => {
|
|
206
|
+
setLoading(true);
|
|
207
|
+
setError(null);
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
const res = await fetch(`${apiBasePath}/settings`);
|
|
211
|
+
if (res.ok) {
|
|
212
|
+
const data = await res.json();
|
|
213
|
+
setSettings(data);
|
|
214
|
+
setEditedRecipients(data.recipients || []);
|
|
215
|
+
setEditedMode(data.digest_mode || 'smart');
|
|
216
|
+
setEditedAlerts(data.alerts || DEFAULT_SETTINGS.alerts);
|
|
217
|
+
setEditedRateLimit(data.rate_limit?.max_per_hour || 10);
|
|
218
|
+
} else if (res.status === 404) {
|
|
219
|
+
// Endpoint not ready - use defaults
|
|
220
|
+
setSettings(DEFAULT_SETTINGS);
|
|
221
|
+
setEditedRecipients([]);
|
|
222
|
+
setEditedMode('smart');
|
|
223
|
+
setEditedAlerts(DEFAULT_SETTINGS.alerts);
|
|
224
|
+
setEditedRateLimit(10);
|
|
225
|
+
} else {
|
|
226
|
+
throw new Error('Failed to fetch settings');
|
|
227
|
+
}
|
|
228
|
+
} catch (err: any) {
|
|
229
|
+
setError(err.message);
|
|
230
|
+
} finally {
|
|
231
|
+
setLoading(false);
|
|
232
|
+
}
|
|
233
|
+
}, [apiBasePath]);
|
|
234
|
+
|
|
235
|
+
useEffect(() => {
|
|
236
|
+
fetchSettings();
|
|
237
|
+
}, [fetchSettings]);
|
|
238
|
+
|
|
239
|
+
// Check for changes
|
|
240
|
+
useEffect(() => {
|
|
241
|
+
if (!settings) {
|
|
242
|
+
setHasChanges(false);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const recipientsChanged = JSON.stringify(settings.recipients) !== JSON.stringify(editedRecipients);
|
|
247
|
+
const modeChanged = settings.digest_mode !== editedMode;
|
|
248
|
+
const alertsChanged = JSON.stringify(settings.alerts) !== JSON.stringify(editedAlerts);
|
|
249
|
+
const rateLimitChanged = settings.rate_limit.max_per_hour !== editedRateLimit;
|
|
250
|
+
|
|
251
|
+
setHasChanges(recipientsChanged || modeChanged || alertsChanged || rateLimitChanged);
|
|
252
|
+
}, [settings, editedRecipients, editedMode, editedAlerts, editedRateLimit]);
|
|
253
|
+
|
|
254
|
+
// Add recipient
|
|
255
|
+
const addRecipient = () => {
|
|
256
|
+
const email = newRecipient.trim().toLowerCase();
|
|
257
|
+
setRecipientError(null);
|
|
258
|
+
|
|
259
|
+
if (!email) return;
|
|
260
|
+
|
|
261
|
+
// Basic email validation
|
|
262
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
263
|
+
if (!emailRegex.test(email)) {
|
|
264
|
+
setRecipientError('Please enter a valid email address');
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (editedRecipients.includes(email)) {
|
|
269
|
+
setRecipientError('This email is already in the list');
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
setEditedRecipients([...editedRecipients, email]);
|
|
274
|
+
setNewRecipient('');
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
// Remove recipient
|
|
278
|
+
const removeRecipient = (email: string) => {
|
|
279
|
+
setEditedRecipients(editedRecipients.filter(r => r !== email));
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
// Update alert config
|
|
283
|
+
const updateAlertConfig = (alertId: keyof AlertsConfig, updates: Partial<AlertConfig>) => {
|
|
284
|
+
setEditedAlerts(prev => ({
|
|
285
|
+
...prev,
|
|
286
|
+
[alertId]: { ...prev[alertId], ...updates },
|
|
287
|
+
}));
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
// Save settings
|
|
291
|
+
const saveSettings = async () => {
|
|
292
|
+
if (!hasChanges) return;
|
|
293
|
+
|
|
294
|
+
setSaving(true);
|
|
295
|
+
setError(null);
|
|
296
|
+
|
|
297
|
+
try {
|
|
298
|
+
const payload: AlertSettings = {
|
|
299
|
+
recipients: editedRecipients,
|
|
300
|
+
digest_mode: editedMode,
|
|
301
|
+
alerts: editedAlerts,
|
|
302
|
+
rate_limit: { max_per_hour: editedRateLimit },
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
const res = await fetch(`${apiBasePath}/settings`, {
|
|
306
|
+
method: 'PUT',
|
|
307
|
+
headers: { 'Content-Type': 'application/json' },
|
|
308
|
+
body: JSON.stringify(payload),
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
if (res.ok) {
|
|
312
|
+
setSuccessMessage('Settings saved successfully');
|
|
313
|
+
setTimeout(() => setSuccessMessage(null), 3000);
|
|
314
|
+
onSave?.(payload);
|
|
315
|
+
fetchSettings();
|
|
316
|
+
} else if (res.status === 404) {
|
|
317
|
+
// Endpoint not ready - simulate success
|
|
318
|
+
setSettings(payload);
|
|
319
|
+
setSuccessMessage('Settings saved (demo mode)');
|
|
320
|
+
setTimeout(() => setSuccessMessage(null), 3000);
|
|
321
|
+
} else {
|
|
322
|
+
throw new Error('Failed to save settings');
|
|
323
|
+
}
|
|
324
|
+
} catch (err: any) {
|
|
325
|
+
setError(err.message);
|
|
326
|
+
} finally {
|
|
327
|
+
setSaving(false);
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
// Send test alert
|
|
332
|
+
const sendTestAlert = async (alertType: string) => {
|
|
333
|
+
setSendingTest(true);
|
|
334
|
+
setError(null);
|
|
335
|
+
|
|
336
|
+
try {
|
|
337
|
+
const res = await fetch(`${apiBasePath}/test`, {
|
|
338
|
+
method: 'POST',
|
|
339
|
+
headers: { 'Content-Type': 'application/json' },
|
|
340
|
+
body: JSON.stringify({
|
|
341
|
+
type: alertType,
|
|
342
|
+
recipient: editedRecipients[0] || 'test@example.com',
|
|
343
|
+
}),
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
if (res.ok) {
|
|
347
|
+
setSuccessMessage(`Test ${alertType.replace('_', ' ')} alert sent!`);
|
|
348
|
+
setTimeout(() => setSuccessMessage(null), 3000);
|
|
349
|
+
} else if (res.status === 404) {
|
|
350
|
+
setSuccessMessage('Test alert sent (demo mode)');
|
|
351
|
+
setTimeout(() => setSuccessMessage(null), 3000);
|
|
352
|
+
} else {
|
|
353
|
+
throw new Error('Failed to send test alert');
|
|
354
|
+
}
|
|
355
|
+
} catch (err: any) {
|
|
356
|
+
setError(err.message);
|
|
357
|
+
} finally {
|
|
358
|
+
setSendingTest(false);
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
if (loading) {
|
|
363
|
+
return (
|
|
364
|
+
<div className="flex items-center justify-center py-12">
|
|
365
|
+
<RefreshCw className="w-8 h-8 animate-spin text-indigo-500" />
|
|
366
|
+
</div>
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return (
|
|
371
|
+
<div className="space-y-6">
|
|
372
|
+
{/* Error/Success Messages */}
|
|
373
|
+
{error && (
|
|
374
|
+
<div className={`flex items-center gap-3 p-4 rounded-lg ${isDark ? 'bg-red-900/30 border-red-700' : 'bg-red-50 border-red-300'} border`}>
|
|
375
|
+
<AlertCircle className={isDark ? 'text-red-400' : 'text-red-500'} size={20} />
|
|
376
|
+
<span className={isDark ? 'text-red-300' : 'text-red-700'}>{error}</span>
|
|
377
|
+
</div>
|
|
378
|
+
)}
|
|
379
|
+
|
|
380
|
+
{successMessage && (
|
|
381
|
+
<div className={`flex items-center gap-3 p-4 rounded-lg ${isDark ? 'bg-green-900/30 border-green-700' : 'bg-green-50 border-green-300'} border`}>
|
|
382
|
+
<Check className={isDark ? 'text-green-400' : 'text-green-500'} size={20} />
|
|
383
|
+
<span className={isDark ? 'text-green-300' : 'text-green-700'}>{successMessage}</span>
|
|
384
|
+
</div>
|
|
385
|
+
)}
|
|
386
|
+
|
|
387
|
+
{/* Section 1: Recipients */}
|
|
388
|
+
<div className={`${themeClasses.cardBg} border rounded-xl overflow-hidden`}>
|
|
389
|
+
<div className={`px-5 py-4 border-b ${isDark ? 'border-slate-700' : 'border-gray-200'}`}>
|
|
390
|
+
<div className="flex items-center gap-2">
|
|
391
|
+
<Mail className={isDark ? 'text-violet-400' : 'text-violet-500'} size={20} />
|
|
392
|
+
<h3 className={`font-semibold ${themeClasses.textPrimary}`}>Alert Recipients</h3>
|
|
393
|
+
</div>
|
|
394
|
+
<p className={`text-sm ${themeClasses.textMuted} mt-1`}>
|
|
395
|
+
Email addresses that receive alert notifications
|
|
396
|
+
</p>
|
|
397
|
+
</div>
|
|
398
|
+
<div className="p-5 space-y-4">
|
|
399
|
+
{/* Recipient list */}
|
|
400
|
+
<div className="flex flex-wrap gap-2">
|
|
401
|
+
{editedRecipients.map((email) => (
|
|
402
|
+
<div
|
|
403
|
+
key={email}
|
|
404
|
+
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg ${themeClasses.tagBg}`}
|
|
405
|
+
>
|
|
406
|
+
<span className={`text-sm ${themeClasses.textPrimary}`}>{email}</span>
|
|
407
|
+
<button
|
|
408
|
+
onClick={() => removeRecipient(email)}
|
|
409
|
+
className={`p-0.5 rounded ${isDark ? 'hover:bg-slate-600' : 'hover:bg-gray-300'} transition-colors`}
|
|
410
|
+
>
|
|
411
|
+
<X size={14} className={themeClasses.textMuted} />
|
|
412
|
+
</button>
|
|
413
|
+
</div>
|
|
414
|
+
))}
|
|
415
|
+
{editedRecipients.length === 0 && (
|
|
416
|
+
<span className={`text-sm ${themeClasses.textMuted}`}>No recipients configured</span>
|
|
417
|
+
)}
|
|
418
|
+
</div>
|
|
419
|
+
|
|
420
|
+
{/* Add recipient input */}
|
|
421
|
+
<div className="flex gap-2">
|
|
422
|
+
<div className="flex-1">
|
|
423
|
+
<input
|
|
424
|
+
type="email"
|
|
425
|
+
value={newRecipient}
|
|
426
|
+
onChange={(e) => {
|
|
427
|
+
setNewRecipient(e.target.value);
|
|
428
|
+
setRecipientError(null);
|
|
429
|
+
}}
|
|
430
|
+
onKeyDown={(e) => {
|
|
431
|
+
if (e.key === 'Enter') {
|
|
432
|
+
e.preventDefault();
|
|
433
|
+
addRecipient();
|
|
434
|
+
}
|
|
435
|
+
}}
|
|
436
|
+
placeholder="Enter email address"
|
|
437
|
+
className={`
|
|
438
|
+
w-full px-3 py-2 rounded-lg text-sm
|
|
439
|
+
${themeClasses.inputBg} ${themeClasses.textPrimary} border
|
|
440
|
+
focus:outline-none focus:ring-2 focus:ring-indigo-500
|
|
441
|
+
${recipientError ? (isDark ? 'border-red-500' : 'border-red-400') : ''}
|
|
442
|
+
`}
|
|
443
|
+
/>
|
|
444
|
+
{recipientError && (
|
|
445
|
+
<p className={`text-xs mt-1 ${isDark ? 'text-red-400' : 'text-red-500'}`}>
|
|
446
|
+
{recipientError}
|
|
447
|
+
</p>
|
|
448
|
+
)}
|
|
449
|
+
</div>
|
|
450
|
+
<button
|
|
451
|
+
onClick={addRecipient}
|
|
452
|
+
className={`
|
|
453
|
+
px-4 py-2 rounded-lg font-medium text-sm transition-colors
|
|
454
|
+
${isDark ? 'bg-indigo-600 hover:bg-indigo-700' : 'bg-indigo-500 hover:bg-indigo-600'}
|
|
455
|
+
text-white flex items-center gap-2
|
|
456
|
+
`}
|
|
457
|
+
>
|
|
458
|
+
<Plus size={16} />
|
|
459
|
+
Add
|
|
460
|
+
</button>
|
|
461
|
+
</div>
|
|
462
|
+
</div>
|
|
463
|
+
</div>
|
|
464
|
+
|
|
465
|
+
{/* Section 2: Delivery Mode */}
|
|
466
|
+
<div className={`${themeClasses.cardBg} border rounded-xl overflow-hidden`}>
|
|
467
|
+
<div className={`px-5 py-4 border-b ${isDark ? 'border-slate-700' : 'border-gray-200'}`}>
|
|
468
|
+
<div className="flex items-center gap-2">
|
|
469
|
+
<Clock className={isDark ? 'text-cyan-400' : 'text-cyan-500'} size={20} />
|
|
470
|
+
<h3 className={`font-semibold ${themeClasses.textPrimary}`}>Delivery Mode</h3>
|
|
471
|
+
</div>
|
|
472
|
+
<p className={`text-sm ${themeClasses.textMuted} mt-1`}>
|
|
473
|
+
How alerts are delivered to recipients
|
|
474
|
+
</p>
|
|
475
|
+
</div>
|
|
476
|
+
<div className="p-5">
|
|
477
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
|
478
|
+
{DELIVERY_MODES.map((mode) => {
|
|
479
|
+
const isSelected = editedMode === mode.value;
|
|
480
|
+
return (
|
|
481
|
+
<button
|
|
482
|
+
key={mode.value}
|
|
483
|
+
onClick={() => setEditedMode(mode.value)}
|
|
484
|
+
className={`
|
|
485
|
+
p-4 rounded-lg border-2 text-left transition-all
|
|
486
|
+
${isSelected
|
|
487
|
+
? 'border-indigo-500 ' + (isDark ? 'bg-indigo-500/10' : 'bg-indigo-50')
|
|
488
|
+
: (isDark ? 'border-slate-700 hover:border-slate-600' : 'border-gray-200 hover:border-gray-300')
|
|
489
|
+
}
|
|
490
|
+
`}
|
|
491
|
+
>
|
|
492
|
+
<div className="flex items-center gap-2 mb-1">
|
|
493
|
+
<div className={`w-3 h-3 rounded-full border-2 flex items-center justify-center ${
|
|
494
|
+
isSelected ? 'border-indigo-500' : (isDark ? 'border-slate-500' : 'border-gray-400')
|
|
495
|
+
}`}>
|
|
496
|
+
{isSelected && <div className="w-1.5 h-1.5 rounded-full bg-indigo-500" />}
|
|
497
|
+
</div>
|
|
498
|
+
<span className={`font-medium ${themeClasses.textPrimary}`}>{mode.label}</span>
|
|
499
|
+
</div>
|
|
500
|
+
<p className={`text-xs ${themeClasses.textMuted} ml-5`}>{mode.description}</p>
|
|
501
|
+
</button>
|
|
502
|
+
);
|
|
503
|
+
})}
|
|
504
|
+
</div>
|
|
505
|
+
</div>
|
|
506
|
+
</div>
|
|
507
|
+
|
|
508
|
+
{/* Section 3: Alert Configuration */}
|
|
509
|
+
<div className={`${themeClasses.cardBg} border rounded-xl overflow-hidden`}>
|
|
510
|
+
<div className={`px-5 py-4 border-b ${isDark ? 'border-slate-700' : 'border-gray-200'}`}>
|
|
511
|
+
<div className="flex items-center gap-2">
|
|
512
|
+
<Bell className={isDark ? 'text-amber-400' : 'text-amber-500'} size={20} />
|
|
513
|
+
<h3 className={`font-semibold ${themeClasses.textPrimary}`}>Alert Types</h3>
|
|
514
|
+
</div>
|
|
515
|
+
<p className={`text-sm ${themeClasses.textMuted} mt-1`}>
|
|
516
|
+
Configure which alerts to receive and their thresholds
|
|
517
|
+
</p>
|
|
518
|
+
</div>
|
|
519
|
+
<div className="divide-y divide-slate-700/50">
|
|
520
|
+
{ALERT_TYPES.map((alertType) => {
|
|
521
|
+
const config = editedAlerts[alertType.id];
|
|
522
|
+
const Icon = alertType.icon;
|
|
523
|
+
|
|
524
|
+
return (
|
|
525
|
+
<div key={alertType.id} className="p-5">
|
|
526
|
+
<div className="flex items-start justify-between gap-4">
|
|
527
|
+
<div className="flex items-start gap-3">
|
|
528
|
+
<div className={`p-2 rounded-lg ${isDark ? 'bg-slate-700' : 'bg-gray-100'}`}>
|
|
529
|
+
<Icon size={18} className={themeClasses.textSecondary} />
|
|
530
|
+
</div>
|
|
531
|
+
<div>
|
|
532
|
+
<div className="flex items-center gap-2">
|
|
533
|
+
<span className={`font-medium ${themeClasses.textPrimary}`}>{alertType.label}</span>
|
|
534
|
+
{alertType.alwaysSent && (
|
|
535
|
+
<span className={`px-2 py-0.5 text-xs rounded ${isDark ? 'bg-red-500/20 text-red-400' : 'bg-red-100 text-red-600'}`}>
|
|
536
|
+
Always sent
|
|
537
|
+
</span>
|
|
538
|
+
)}
|
|
539
|
+
</div>
|
|
540
|
+
<p className={`text-sm ${themeClasses.textMuted}`}>{alertType.description}</p>
|
|
541
|
+
</div>
|
|
542
|
+
</div>
|
|
543
|
+
|
|
544
|
+
<div className="flex items-center gap-4">
|
|
545
|
+
{/* Threshold input */}
|
|
546
|
+
{alertType.hasThreshold && (
|
|
547
|
+
<div className="flex items-center gap-2">
|
|
548
|
+
<input
|
|
549
|
+
type="number"
|
|
550
|
+
min="1"
|
|
551
|
+
max="100"
|
|
552
|
+
value={config.threshold ?? config.threshold_pct ?? 10}
|
|
553
|
+
onChange={(e) => {
|
|
554
|
+
const val = parseInt(e.target.value) || 10;
|
|
555
|
+
if (alertType.thresholdUnit?.includes('%')) {
|
|
556
|
+
updateAlertConfig(alertType.id, { threshold_pct: val });
|
|
557
|
+
} else {
|
|
558
|
+
updateAlertConfig(alertType.id, { threshold: val });
|
|
559
|
+
}
|
|
560
|
+
}}
|
|
561
|
+
disabled={!config.enabled && !alertType.alwaysSent}
|
|
562
|
+
className={`
|
|
563
|
+
w-16 px-2 py-1.5 rounded text-sm text-center
|
|
564
|
+
${themeClasses.inputBg} ${themeClasses.textPrimary} border
|
|
565
|
+
focus:outline-none focus:ring-2 focus:ring-indigo-500
|
|
566
|
+
disabled:opacity-50
|
|
567
|
+
`}
|
|
568
|
+
/>
|
|
569
|
+
<span className={`text-xs ${themeClasses.textMuted} whitespace-nowrap`}>
|
|
570
|
+
{alertType.thresholdUnit}
|
|
571
|
+
</span>
|
|
572
|
+
</div>
|
|
573
|
+
)}
|
|
574
|
+
|
|
575
|
+
{/* Toggle */}
|
|
576
|
+
{!alertType.alwaysSent && (
|
|
577
|
+
<button
|
|
578
|
+
onClick={() => updateAlertConfig(alertType.id, { enabled: !config.enabled })}
|
|
579
|
+
className={`
|
|
580
|
+
relative w-11 h-6 rounded-full transition-colors
|
|
581
|
+
${config.enabled
|
|
582
|
+
? (isDark ? 'bg-indigo-600' : 'bg-indigo-500')
|
|
583
|
+
: (isDark ? 'bg-slate-600' : 'bg-gray-300')
|
|
584
|
+
}
|
|
585
|
+
`}
|
|
586
|
+
>
|
|
587
|
+
<span
|
|
588
|
+
className={`
|
|
589
|
+
absolute top-1 left-1 w-4 h-4 rounded-full bg-white transition-transform
|
|
590
|
+
${config.enabled ? 'translate-x-5' : ''}
|
|
591
|
+
`}
|
|
592
|
+
/>
|
|
593
|
+
</button>
|
|
594
|
+
)}
|
|
595
|
+
</div>
|
|
596
|
+
</div>
|
|
597
|
+
</div>
|
|
598
|
+
);
|
|
599
|
+
})}
|
|
600
|
+
</div>
|
|
601
|
+
</div>
|
|
602
|
+
|
|
603
|
+
{/* Section 4: Rate Limiting */}
|
|
604
|
+
<div className={`${themeClasses.cardBg} border rounded-xl overflow-hidden`}>
|
|
605
|
+
<div className={`px-5 py-4 border-b ${isDark ? 'border-slate-700' : 'border-gray-200'}`}>
|
|
606
|
+
<div className="flex items-center gap-2">
|
|
607
|
+
<Zap className={isDark ? 'text-emerald-400' : 'text-emerald-500'} size={20} />
|
|
608
|
+
<h3 className={`font-semibold ${themeClasses.textPrimary}`}>Rate Limiting</h3>
|
|
609
|
+
</div>
|
|
610
|
+
</div>
|
|
611
|
+
<div className="p-5">
|
|
612
|
+
<div className="flex items-center gap-4">
|
|
613
|
+
<label className={`text-sm ${themeClasses.textSecondary}`}>Maximum alerts:</label>
|
|
614
|
+
<input
|
|
615
|
+
type="number"
|
|
616
|
+
min="1"
|
|
617
|
+
max="100"
|
|
618
|
+
value={editedRateLimit}
|
|
619
|
+
onChange={(e) => setEditedRateLimit(parseInt(e.target.value) || 10)}
|
|
620
|
+
className={`
|
|
621
|
+
w-20 px-3 py-2 rounded-lg text-sm
|
|
622
|
+
${themeClasses.inputBg} ${themeClasses.textPrimary} border
|
|
623
|
+
focus:outline-none focus:ring-2 focus:ring-indigo-500
|
|
624
|
+
`}
|
|
625
|
+
/>
|
|
626
|
+
<span className={`text-sm ${themeClasses.textMuted}`}>per hour</span>
|
|
627
|
+
</div>
|
|
628
|
+
<div className={`flex items-start gap-2 mt-3 text-xs ${themeClasses.textMuted}`}>
|
|
629
|
+
<Info size={14} className="flex-shrink-0 mt-0.5" />
|
|
630
|
+
<span>Critical alerts (Storage Critical, Agent Expired) are always sent regardless of this limit.</span>
|
|
631
|
+
</div>
|
|
632
|
+
</div>
|
|
633
|
+
</div>
|
|
634
|
+
|
|
635
|
+
{/* Section 5: Test & History */}
|
|
636
|
+
<div className={`${themeClasses.cardBg} border rounded-xl overflow-hidden`}>
|
|
637
|
+
<div className={`px-5 py-4 border-b ${isDark ? 'border-slate-700' : 'border-gray-200'}`}>
|
|
638
|
+
<div className="flex items-center gap-2">
|
|
639
|
+
<Send className={isDark ? 'text-blue-400' : 'text-blue-500'} size={20} />
|
|
640
|
+
<h3 className={`font-semibold ${themeClasses.textPrimary}`}>Test & History</h3>
|
|
641
|
+
</div>
|
|
642
|
+
</div>
|
|
643
|
+
<div className="p-5">
|
|
644
|
+
<div className="flex flex-wrap gap-3">
|
|
645
|
+
{/* Send Test Alert dropdown */}
|
|
646
|
+
<div className="relative">
|
|
647
|
+
<select
|
|
648
|
+
disabled={sendingTest || editedRecipients.length === 0}
|
|
649
|
+
onChange={(e) => {
|
|
650
|
+
if (e.target.value) {
|
|
651
|
+
sendTestAlert(e.target.value);
|
|
652
|
+
e.target.value = '';
|
|
653
|
+
}
|
|
654
|
+
}}
|
|
655
|
+
className={`
|
|
656
|
+
px-4 py-2.5 pr-10 rounded-lg text-sm font-medium cursor-pointer appearance-none
|
|
657
|
+
${isDark ? 'bg-blue-900/30 text-blue-300 border-blue-700 hover:bg-blue-900/50' : 'bg-blue-50 text-blue-700 border-blue-300 hover:bg-blue-100'}
|
|
658
|
+
border focus:outline-none focus:ring-2 focus:ring-blue-500
|
|
659
|
+
disabled:opacity-50 disabled:cursor-not-allowed
|
|
660
|
+
`}
|
|
661
|
+
>
|
|
662
|
+
<option value="">Send Test Alert...</option>
|
|
663
|
+
{TEST_ALERT_OPTIONS.map((opt) => (
|
|
664
|
+
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
|
665
|
+
))}
|
|
666
|
+
</select>
|
|
667
|
+
<ChevronDown className={`absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 pointer-events-none ${isDark ? 'text-blue-400' : 'text-blue-500'}`} />
|
|
668
|
+
</div>
|
|
669
|
+
|
|
670
|
+
{/* View History button */}
|
|
671
|
+
<button
|
|
672
|
+
onClick={() => {
|
|
673
|
+
// TODO: Open history modal or navigate to history page
|
|
674
|
+
window.open(`${apiBasePath}/history`, '_blank');
|
|
675
|
+
}}
|
|
676
|
+
className={`
|
|
677
|
+
px-4 py-2.5 rounded-lg text-sm font-medium flex items-center gap-2
|
|
678
|
+
${isDark ? 'bg-slate-700 text-white hover:bg-slate-600' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}
|
|
679
|
+
border ${isDark ? 'border-slate-600' : 'border-gray-300'}
|
|
680
|
+
`}
|
|
681
|
+
>
|
|
682
|
+
<History size={16} />
|
|
683
|
+
View Alert History
|
|
684
|
+
</button>
|
|
685
|
+
</div>
|
|
686
|
+
|
|
687
|
+
{editedRecipients.length === 0 && (
|
|
688
|
+
<p className={`text-xs mt-3 ${themeClasses.textMuted}`}>
|
|
689
|
+
Add at least one recipient to send test alerts.
|
|
690
|
+
</p>
|
|
691
|
+
)}
|
|
692
|
+
</div>
|
|
693
|
+
</div>
|
|
694
|
+
|
|
695
|
+
{/* Save Button */}
|
|
696
|
+
<div className="flex items-center justify-end gap-4">
|
|
697
|
+
{hasChanges && (
|
|
698
|
+
<span className={`text-sm ${themeClasses.textMuted}`}>
|
|
699
|
+
You have unsaved changes
|
|
700
|
+
</span>
|
|
701
|
+
)}
|
|
702
|
+
<button
|
|
703
|
+
onClick={saveSettings}
|
|
704
|
+
disabled={!hasChanges || saving}
|
|
705
|
+
className={`
|
|
706
|
+
px-6 py-2.5 rounded-lg font-medium text-sm transition-all
|
|
707
|
+
${hasChanges
|
|
708
|
+
? 'bg-indigo-600 text-white hover:bg-indigo-700'
|
|
709
|
+
: (isDark ? 'bg-slate-700 text-slate-400' : 'bg-gray-200 text-gray-400')
|
|
710
|
+
}
|
|
711
|
+
disabled:cursor-not-allowed
|
|
712
|
+
`}
|
|
713
|
+
>
|
|
714
|
+
{saving ? (
|
|
715
|
+
<span className="flex items-center gap-2">
|
|
716
|
+
<RefreshCw className="w-4 h-4 animate-spin" />
|
|
717
|
+
Saving...
|
|
718
|
+
</span>
|
|
719
|
+
) : (
|
|
720
|
+
'Save Settings'
|
|
721
|
+
)}
|
|
722
|
+
</button>
|
|
723
|
+
</div>
|
|
724
|
+
</div>
|
|
725
|
+
);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
export default AlertSettingsTab;
|