astro-tractstack 2.0.0-rc.26 → 2.0.0-rc.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect,
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
2
|
import { useStore } from '@nanostores/react';
|
|
3
3
|
import { epinetCustomFilters } from '@/stores/analytics';
|
|
4
4
|
import { TractStackAPI } from '@/utils/api';
|
|
@@ -20,88 +20,115 @@ interface FetchAnalyticsProps {
|
|
|
20
20
|
onAnalyticsUpdate: (analytics: AnalyticsState) => void;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
23
|
+
// Global singleton state to prevent multi-component conflicts
|
|
24
|
+
class AnalyticsService {
|
|
25
|
+
private static instance: AnalyticsService;
|
|
26
|
+
private isInitialized = false;
|
|
27
|
+
private activeRequest: AbortController | null = null;
|
|
28
|
+
private requestCache = new Map<string, { data: any; timestamp: number }>();
|
|
29
|
+
private readonly CACHE_TTL = 5000; // 5 seconds
|
|
30
|
+
private readonly DEBOUNCE_MS = 300;
|
|
31
|
+
private debounceTimer: NodeJS.Timeout | null = null;
|
|
32
|
+
private floodProtection = {
|
|
33
|
+
requestCount: 0,
|
|
34
|
+
windowStart: 0,
|
|
35
|
+
isBlocked: false,
|
|
36
|
+
};
|
|
37
|
+
private readonly FLOOD_WINDOW_MS = 10000; // 10 seconds
|
|
38
|
+
private readonly FLOOD_THRESHOLD = 5;
|
|
39
|
+
|
|
40
|
+
static getInstance(): AnalyticsService {
|
|
41
|
+
if (!AnalyticsService.instance) {
|
|
42
|
+
AnalyticsService.instance = new AnalyticsService();
|
|
43
|
+
}
|
|
44
|
+
return AnalyticsService.instance;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private getCacheKey(params: URLSearchParams): string {
|
|
48
|
+
const sorted = new URLSearchParams([...params.entries()].sort());
|
|
49
|
+
return sorted.toString();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private isFloodBlocked(): boolean {
|
|
53
|
+
const now = Date.now();
|
|
54
|
+
|
|
55
|
+
// Reset window if needed
|
|
56
|
+
if (now - this.floodProtection.windowStart > this.FLOOD_WINDOW_MS) {
|
|
57
|
+
this.floodProtection.requestCount = 0;
|
|
58
|
+
this.floodProtection.windowStart = now;
|
|
59
|
+
this.floodProtection.isBlocked = false;
|
|
60
|
+
}
|
|
51
61
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
62
|
+
// Check if blocked
|
|
63
|
+
if (this.floodProtection.isBlocked) {
|
|
64
|
+
if (VERBOSE) console.log('🚫 Request blocked by flood protection');
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Increment counter and check threshold
|
|
69
|
+
this.floodProtection.requestCount++;
|
|
70
|
+
if (this.floodProtection.requestCount > this.FLOOD_THRESHOLD) {
|
|
71
|
+
this.floodProtection.isBlocked = true;
|
|
72
|
+
if (VERBOSE) console.log('🚨 Flood protection activated');
|
|
73
|
+
|
|
74
|
+
// Auto-unblock after delay
|
|
75
|
+
setTimeout(() => {
|
|
76
|
+
this.floodProtection.isBlocked = false;
|
|
77
|
+
if (VERBOSE) console.log('✅ Flood protection deactivated');
|
|
78
|
+
}, 30000); // 30 second cooldown
|
|
79
|
+
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private getCachedResponse(cacheKey: string): any | null {
|
|
87
|
+
const cached = this.requestCache.get(cacheKey);
|
|
88
|
+
if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) {
|
|
89
|
+
if (VERBOSE) console.log('📦 Using cached response');
|
|
90
|
+
return cached.data;
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private setCachedResponse(cacheKey: string, data: any): void {
|
|
96
|
+
this.requestCache.set(cacheKey, { data, timestamp: Date.now() });
|
|
97
|
+
|
|
98
|
+
// Cleanup old cache entries
|
|
99
|
+
const cutoff = Date.now() - this.CACHE_TTL;
|
|
100
|
+
for (const [key, value] of this.requestCache.entries()) {
|
|
101
|
+
if (value.timestamp < cutoff) {
|
|
102
|
+
this.requestCache.delete(key);
|
|
57
103
|
}
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
visitorType: $epinetCustomFilters.visitorType,
|
|
70
|
-
selectedUserId: $epinetCustomFilters.selectedUserId,
|
|
71
|
-
},
|
|
72
|
-
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async fetchAnalytics(
|
|
108
|
+
filters: any,
|
|
109
|
+
onUpdate: (data: AnalyticsState) => void
|
|
110
|
+
): Promise<void> {
|
|
111
|
+
// Flood protection
|
|
112
|
+
if (this.isFloodBlocked()) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
73
115
|
|
|
74
116
|
try {
|
|
75
|
-
//
|
|
76
|
-
if (
|
|
77
|
-
|
|
78
|
-
|
|
117
|
+
// Cancel any existing request
|
|
118
|
+
if (this.activeRequest) {
|
|
119
|
+
this.activeRequest.abort();
|
|
120
|
+
if (VERBOSE) console.log('🛑 Cancelled previous request');
|
|
79
121
|
}
|
|
80
122
|
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
onAnalyticsUpdate({
|
|
84
|
-
dashboard: null,
|
|
85
|
-
leads: null,
|
|
86
|
-
epinet: null,
|
|
87
|
-
userCounts: [],
|
|
88
|
-
hourlyNodeActivity: {},
|
|
89
|
-
isLoading: true,
|
|
90
|
-
status: 'loading',
|
|
91
|
-
error: null,
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
const { startTimeUTC, endTimeUTC, visitorType, selectedUserId } =
|
|
95
|
-
$epinetCustomFilters;
|
|
123
|
+
// Create new abort controller
|
|
124
|
+
this.activeRequest = new AbortController();
|
|
96
125
|
|
|
97
|
-
// Build URL parameters
|
|
126
|
+
// Build URL parameters
|
|
98
127
|
const params = new URLSearchParams();
|
|
99
|
-
|
|
100
|
-
if (startTimeUTC && endTimeUTC) {
|
|
101
|
-
// Convert UTC timestamps to hours-back integers (what backend expects)
|
|
128
|
+
if (filters.startTimeUTC && filters.endTimeUTC) {
|
|
102
129
|
const now = new Date();
|
|
103
|
-
const startTime = new Date(startTimeUTC);
|
|
104
|
-
const endTime = new Date(endTimeUTC);
|
|
130
|
+
const startTime = new Date(filters.startTimeUTC);
|
|
131
|
+
const endTime = new Date(filters.endTimeUTC);
|
|
105
132
|
|
|
106
133
|
const startHour = Math.ceil(
|
|
107
134
|
(now.getTime() - startTime.getTime()) / (1000 * 60 * 60)
|
|
@@ -112,27 +139,42 @@ export default function FetchAnalytics({
|
|
|
112
139
|
|
|
113
140
|
params.append('startHour', startHour.toString());
|
|
114
141
|
params.append('endHour', endHour.toString());
|
|
142
|
+
}
|
|
115
143
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
144
|
+
if (filters.visitorType)
|
|
145
|
+
params.append('visitorType', filters.visitorType);
|
|
146
|
+
if (filters.selectedUserId)
|
|
147
|
+
params.append('userId', filters.selectedUserId);
|
|
148
|
+
|
|
149
|
+
const cacheKey = this.getCacheKey(params);
|
|
150
|
+
|
|
151
|
+
// Check cache first
|
|
152
|
+
const cachedData = this.getCachedResponse(cacheKey);
|
|
153
|
+
if (cachedData) {
|
|
154
|
+
onUpdate(cachedData);
|
|
155
|
+
return;
|
|
124
156
|
}
|
|
125
157
|
|
|
126
|
-
|
|
127
|
-
|
|
158
|
+
// Set loading state
|
|
159
|
+
onUpdate({
|
|
160
|
+
dashboard: null,
|
|
161
|
+
leads: null,
|
|
162
|
+
epinet: null,
|
|
163
|
+
userCounts: [],
|
|
164
|
+
hourlyNodeActivity: {},
|
|
165
|
+
isLoading: true,
|
|
166
|
+
status: 'loading',
|
|
167
|
+
error: null,
|
|
168
|
+
});
|
|
128
169
|
|
|
129
|
-
//
|
|
170
|
+
// Make request using existing TractStackAPI
|
|
130
171
|
const api = new TractStackAPI(
|
|
131
172
|
window.TRACTSTACK_CONFIG?.tenantId || 'default'
|
|
132
173
|
);
|
|
133
174
|
const endpoint = `/api/v1/analytics/all${params.toString() ? `?${params.toString()}` : ''}`;
|
|
134
175
|
|
|
135
|
-
if (VERBOSE) console.log('
|
|
176
|
+
if (VERBOSE) console.log('🔥 Making API request', { endpoint });
|
|
177
|
+
|
|
136
178
|
const response = await api.get(endpoint);
|
|
137
179
|
|
|
138
180
|
if (!response.success) {
|
|
@@ -140,15 +182,8 @@ export default function FetchAnalytics({
|
|
|
140
182
|
}
|
|
141
183
|
|
|
142
184
|
const data = response.data;
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
hasData: !!data,
|
|
146
|
-
dataKeys: data ? Object.keys(data) : [],
|
|
147
|
-
userCountsLength: data?.userCounts?.length || 0,
|
|
148
|
-
hasHourlyNodeActivity: !!data?.hourlyNodeActivity,
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
// Check if data is still loading - add polling logic here
|
|
185
|
+
|
|
186
|
+
// Check if data is still loading - implement polling logic
|
|
152
187
|
const isStillLoading =
|
|
153
188
|
data?.status === 'loading' ||
|
|
154
189
|
data?.status === 'refreshing' ||
|
|
@@ -159,63 +194,42 @@ export default function FetchAnalytics({
|
|
|
159
194
|
data?.epinet?.status === 'loading' ||
|
|
160
195
|
data?.epinet?.status === 'refreshing';
|
|
161
196
|
|
|
162
|
-
if (
|
|
163
|
-
console.log('
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
const delayMs =
|
|
192
|
-
POLLING_DELAYS[pollingAttempts] ||
|
|
193
|
-
POLLING_DELAYS[POLLING_DELAYS.length - 1];
|
|
194
|
-
|
|
195
|
-
const newTimer = setTimeout(() => {
|
|
196
|
-
setPollingAttempts(pollingAttempts + 1);
|
|
197
|
-
fetchAllAnalytics();
|
|
198
|
-
}, delayMs);
|
|
199
|
-
|
|
200
|
-
setPollingTimer(newTimer);
|
|
201
|
-
|
|
202
|
-
// Update with partial data but keep loading state
|
|
203
|
-
onAnalyticsUpdate({
|
|
204
|
-
dashboard: data.dashboard,
|
|
205
|
-
leads: data.leads,
|
|
206
|
-
epinet: data.epinet,
|
|
207
|
-
userCounts: data.userCounts || [],
|
|
208
|
-
hourlyNodeActivity: data.hourlyNodeActivity || {},
|
|
209
|
-
status: 'loading',
|
|
210
|
-
error: null,
|
|
211
|
-
isLoading: true,
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
return;
|
|
197
|
+
if (isStillLoading) {
|
|
198
|
+
if (VERBOSE) console.log('⏳ Backend data still loading, will poll...');
|
|
199
|
+
|
|
200
|
+
// Update with partial data but keep loading state
|
|
201
|
+
const partialAnalytics = {
|
|
202
|
+
dashboard: data.dashboard,
|
|
203
|
+
leads: data.leads,
|
|
204
|
+
epinet: data.epinet,
|
|
205
|
+
userCounts: data.userCounts || [],
|
|
206
|
+
hourlyNodeActivity: data.hourlyNodeActivity || {},
|
|
207
|
+
status: 'loading',
|
|
208
|
+
error: null,
|
|
209
|
+
isLoading: true,
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
onUpdate(partialAnalytics);
|
|
213
|
+
|
|
214
|
+
// Schedule polling retry - use the cache key to prevent multiple polls
|
|
215
|
+
const pollKey = `poll_${cacheKey}`;
|
|
216
|
+
if (!this.requestCache.has(pollKey)) {
|
|
217
|
+
this.requestCache.set(pollKey, { data: null, timestamp: Date.now() });
|
|
218
|
+
|
|
219
|
+
setTimeout(() => {
|
|
220
|
+
this.requestCache.delete(pollKey);
|
|
221
|
+
// Clear the main cache entry to force fresh request
|
|
222
|
+
this.requestCache.delete(cacheKey);
|
|
223
|
+
// Retry the fetch
|
|
224
|
+
this.fetchAnalytics(filters, onUpdate);
|
|
225
|
+
}, 2000);
|
|
215
226
|
}
|
|
227
|
+
|
|
228
|
+
return;
|
|
216
229
|
}
|
|
217
230
|
|
|
218
|
-
|
|
231
|
+
// Process successful response
|
|
232
|
+
const analyticsData = {
|
|
219
233
|
dashboard: data.dashboard,
|
|
220
234
|
leads: data.leads,
|
|
221
235
|
epinet: data.epinet,
|
|
@@ -226,90 +240,62 @@ export default function FetchAnalytics({
|
|
|
226
240
|
isLoading: false,
|
|
227
241
|
};
|
|
228
242
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
// Update
|
|
233
|
-
|
|
234
|
-
console.log('🔄 BEFORE store update', {
|
|
235
|
-
currentStoreRef: epinetCustomFilters.get(),
|
|
236
|
-
aboutToSet: {
|
|
237
|
-
userCounts: data.userCounts?.length || 0,
|
|
238
|
-
hourlyNodeActivity: !!data.hourlyNodeActivity,
|
|
239
|
-
},
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
epinetCustomFilters.set(window.TRACTSTACK_CONFIG?.tenantId || 'default', {
|
|
243
|
-
...$epinetCustomFilters,
|
|
244
|
-
userCounts: data.userCounts || [],
|
|
245
|
-
hourlyNodeActivity: data.hourlyNodeActivity || {},
|
|
246
|
-
});
|
|
243
|
+
// Cache the response
|
|
244
|
+
this.setCachedResponse(cacheKey, analyticsData);
|
|
245
|
+
|
|
246
|
+
// Update caller
|
|
247
|
+
onUpdate(analyticsData);
|
|
247
248
|
|
|
248
|
-
|
|
249
|
-
setPollingAttempts(0);
|
|
250
|
-
setPollingStartTime(null);
|
|
249
|
+
if (VERBOSE) console.log('✅ Analytics request completed successfully');
|
|
251
250
|
|
|
252
|
-
|
|
253
|
-
console.log('🔄 AFTER store update', {
|
|
254
|
-
newStoreRef: epinetCustomFilters.get(),
|
|
255
|
-
});
|
|
251
|
+
this.activeRequest = null;
|
|
256
252
|
} catch (error) {
|
|
257
|
-
|
|
253
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
254
|
+
if (VERBOSE) console.log('🔄 Request aborted');
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
258
257
|
|
|
259
|
-
|
|
260
|
-
error instanceof Error ? error.message : 'Unknown error occurred';
|
|
258
|
+
console.error('❌ Analytics fetch error:', error);
|
|
261
259
|
|
|
262
|
-
|
|
260
|
+
onUpdate({
|
|
263
261
|
dashboard: null,
|
|
264
262
|
leads: null,
|
|
265
263
|
epinet: null,
|
|
266
264
|
userCounts: [],
|
|
267
265
|
hourlyNodeActivity: {},
|
|
268
266
|
status: 'error',
|
|
269
|
-
error:
|
|
267
|
+
error:
|
|
268
|
+
error instanceof Error ? error.message : 'Unknown error occurred',
|
|
270
269
|
isLoading: false,
|
|
271
270
|
});
|
|
272
271
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
console.log(
|
|
277
|
-
'🔄 Scheduling retry due to error, attempt',
|
|
278
|
-
pollingAttempts + 1
|
|
279
|
-
);
|
|
280
|
-
|
|
281
|
-
const delayMs =
|
|
282
|
-
POLLING_DELAYS[pollingAttempts] ||
|
|
283
|
-
POLLING_DELAYS[POLLING_DELAYS.length - 1];
|
|
284
|
-
|
|
285
|
-
const newTimer = setTimeout(() => {
|
|
286
|
-
setPollingAttempts(pollingAttempts + 1);
|
|
287
|
-
fetchAllAnalytics();
|
|
288
|
-
}, delayMs);
|
|
272
|
+
this.activeRequest = null;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
289
275
|
|
|
290
|
-
|
|
291
|
-
|
|
276
|
+
cleanup(): void {
|
|
277
|
+
if (this.activeRequest) {
|
|
278
|
+
this.activeRequest.abort();
|
|
279
|
+
this.activeRequest = null;
|
|
292
280
|
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
pollingAttempts,
|
|
299
|
-
]);
|
|
281
|
+
if (this.debounceTimer) {
|
|
282
|
+
clearTimeout(this.debounceTimer);
|
|
283
|
+
this.debounceTimer = null;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
300
286
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
if (!isInitialized.current && !isInitializing.current) {
|
|
304
|
-
if (VERBOSE) console.log('🏁 Initializing FetchAnalytics');
|
|
305
|
-
isInitializing.current = true;
|
|
287
|
+
initializeFilters(tenantId: string): void {
|
|
288
|
+
if (this.isInitialized) return;
|
|
306
289
|
|
|
307
|
-
|
|
308
|
-
const oneWeekAgoUTC = new Date(
|
|
309
|
-
nowUTC.getTime() - 7 * 24 * 60 * 60 * 1000
|
|
310
|
-
);
|
|
290
|
+
if (VERBOSE) console.log('🏁 Initializing analytics filters');
|
|
311
291
|
|
|
312
|
-
|
|
292
|
+
const nowUTC = new Date();
|
|
293
|
+
const oneWeekAgoUTC = new Date(nowUTC.getTime() - 7 * 24 * 60 * 60 * 1000);
|
|
294
|
+
|
|
295
|
+
// Only set if not already initialized to prevent store churn
|
|
296
|
+
const current = epinetCustomFilters.get();
|
|
297
|
+
if (!current.enabled) {
|
|
298
|
+
epinetCustomFilters.set(tenantId, {
|
|
313
299
|
enabled: true,
|
|
314
300
|
visitorType: 'all',
|
|
315
301
|
selectedUserId: null,
|
|
@@ -318,32 +304,94 @@ export default function FetchAnalytics({
|
|
|
318
304
|
userCounts: [],
|
|
319
305
|
hourlyNodeActivity: {},
|
|
320
306
|
});
|
|
307
|
+
}
|
|
321
308
|
|
|
322
|
-
|
|
323
|
-
|
|
309
|
+
this.isInitialized = true;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
debouncedFetch(filters: any, onUpdate: (data: AnalyticsState) => void): void {
|
|
313
|
+
// Clear existing debounce timer
|
|
314
|
+
if (this.debounceTimer) {
|
|
315
|
+
clearTimeout(this.debounceTimer);
|
|
324
316
|
}
|
|
317
|
+
|
|
318
|
+
// Set new debounced fetch
|
|
319
|
+
this.debounceTimer = setTimeout(() => {
|
|
320
|
+
this.fetchAnalytics(filters, onUpdate);
|
|
321
|
+
}, this.DEBOUNCE_MS);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export default function FetchAnalytics({
|
|
326
|
+
onAnalyticsUpdate,
|
|
327
|
+
}: FetchAnalyticsProps) {
|
|
328
|
+
const $epinetCustomFilters = useStore(epinetCustomFilters);
|
|
329
|
+
const analyticsService = useRef(AnalyticsService.getInstance());
|
|
330
|
+
const lastFiltersRef = useRef<string>('');
|
|
331
|
+
|
|
332
|
+
if (VERBOSE) {
|
|
333
|
+
console.log('🔄 FetchAnalytics render', {
|
|
334
|
+
filters: {
|
|
335
|
+
startTimeUTC: $epinetCustomFilters.startTimeUTC,
|
|
336
|
+
endTimeUTC: $epinetCustomFilters.endTimeUTC,
|
|
337
|
+
visitorType: $epinetCustomFilters.visitorType,
|
|
338
|
+
selectedUserId: $epinetCustomFilters.selectedUserId,
|
|
339
|
+
},
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Cleanup on unmount
|
|
344
|
+
useEffect(() => {
|
|
345
|
+
return () => {
|
|
346
|
+
analyticsService.current.cleanup();
|
|
347
|
+
};
|
|
325
348
|
}, []);
|
|
326
349
|
|
|
327
|
-
//
|
|
350
|
+
// Initialize filters once
|
|
351
|
+
useEffect(() => {
|
|
352
|
+
const tenantId = window.TRACTSTACK_CONFIG?.tenantId || 'default';
|
|
353
|
+
analyticsService.current.initializeFilters(tenantId);
|
|
354
|
+
}, []);
|
|
355
|
+
|
|
356
|
+
// Debounced fetch when filters change
|
|
328
357
|
useEffect(() => {
|
|
329
358
|
if (
|
|
330
|
-
|
|
331
|
-
$epinetCustomFilters.
|
|
332
|
-
$epinetCustomFilters.
|
|
333
|
-
$epinetCustomFilters.
|
|
334
|
-
$epinetCustomFilters.endTimeUTC !== null
|
|
359
|
+
!$epinetCustomFilters.enabled ||
|
|
360
|
+
$epinetCustomFilters.visitorType === null ||
|
|
361
|
+
$epinetCustomFilters.startTimeUTC === null ||
|
|
362
|
+
$epinetCustomFilters.endTimeUTC === null
|
|
335
363
|
) {
|
|
336
|
-
|
|
337
|
-
setPollingAttempts(0); // Reset polling attempts when filters change
|
|
338
|
-
setPollingStartTime(null); // Reset polling start time
|
|
339
|
-
fetchAllAnalytics();
|
|
364
|
+
return;
|
|
340
365
|
}
|
|
366
|
+
|
|
367
|
+
// Create stable filter signature to prevent unnecessary fetches
|
|
368
|
+
const filtersSignature = JSON.stringify({
|
|
369
|
+
startTimeUTC: $epinetCustomFilters.startTimeUTC,
|
|
370
|
+
endTimeUTC: $epinetCustomFilters.endTimeUTC,
|
|
371
|
+
visitorType: $epinetCustomFilters.visitorType,
|
|
372
|
+
selectedUserId: $epinetCustomFilters.selectedUserId,
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
// Skip if filters haven't actually changed
|
|
376
|
+
if (filtersSignature === lastFiltersRef.current) {
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
lastFiltersRef.current = filtersSignature;
|
|
381
|
+
|
|
382
|
+
if (VERBOSE) console.log('🔄 Filters changed, debouncing fetch');
|
|
383
|
+
|
|
384
|
+
analyticsService.current.debouncedFetch(
|
|
385
|
+
$epinetCustomFilters,
|
|
386
|
+
onAnalyticsUpdate
|
|
387
|
+
);
|
|
341
388
|
}, [
|
|
342
389
|
$epinetCustomFilters.enabled,
|
|
343
390
|
$epinetCustomFilters.visitorType,
|
|
344
391
|
$epinetCustomFilters.selectedUserId,
|
|
345
392
|
$epinetCustomFilters.startTimeUTC,
|
|
346
393
|
$epinetCustomFilters.endTimeUTC,
|
|
394
|
+
onAnalyticsUpdate,
|
|
347
395
|
]);
|
|
348
396
|
|
|
349
397
|
return null;
|
|
@@ -161,11 +161,10 @@ const pollingState = new Map<
|
|
|
161
161
|
}
|
|
162
162
|
>();
|
|
163
163
|
|
|
164
|
-
|
|
165
|
-
const
|
|
166
|
-
const
|
|
167
|
-
const
|
|
168
|
-
const MAX_POLLING_INTERVAL = 32000; // 32 seconds max interval
|
|
164
|
+
const MAX_POLLING_ATTEMPTS = 25;
|
|
165
|
+
const MAX_POLLING_DURATION = 10 * 60 * 1000; // 10 minutes
|
|
166
|
+
const BASE_POLLING_INTERVAL = 10000; // 10 seconds
|
|
167
|
+
const MAX_POLLING_INTERVAL = 30000; // 30 seconds
|
|
169
168
|
|
|
170
169
|
const fetchingStates = new Map<string, boolean>();
|
|
171
170
|
|
|
@@ -203,7 +202,7 @@ export async function loadOrphanAnalysis(): Promise<void> {
|
|
|
203
202
|
|
|
204
203
|
updateTenantState(tenantId, {
|
|
205
204
|
data,
|
|
206
|
-
isLoading:
|
|
205
|
+
isLoading: data.status === 'loading', // Only stop loading if complete
|
|
207
206
|
error: null,
|
|
208
207
|
lastFetched: Date.now(),
|
|
209
208
|
});
|
|
@@ -239,7 +238,7 @@ function startPolling(tenantId: string): void {
|
|
|
239
238
|
lastAttemptTime: startTime,
|
|
240
239
|
});
|
|
241
240
|
|
|
242
|
-
// Start the first poll
|
|
241
|
+
// Start the first poll
|
|
243
242
|
scheduleNextPoll(tenantId);
|
|
244
243
|
}
|
|
245
244
|
|
|
@@ -260,21 +259,21 @@ function scheduleNextPoll(tenantId: string): void {
|
|
|
260
259
|
const elapsed = Date.now() - state.startTime;
|
|
261
260
|
if (elapsed >= MAX_POLLING_DURATION) {
|
|
262
261
|
console.warn(
|
|
263
|
-
`Orphan analysis polling stopped: Maximum duration (${
|
|
262
|
+
`Orphan analysis polling stopped: Maximum duration (${
|
|
263
|
+
MAX_POLLING_DURATION / 1000
|
|
264
|
+
}s) exceeded for tenant ${tenantId}`
|
|
264
265
|
);
|
|
265
266
|
handlePollingFailure(tenantId, 'Polling timeout exceeded');
|
|
266
267
|
return;
|
|
267
268
|
}
|
|
268
269
|
|
|
269
|
-
//
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
);
|
|
277
|
-
}
|
|
270
|
+
// This is more suitable for long-running jobs, as it spaces out requests
|
|
271
|
+
// even when the server responds successfully with a 'loading' status.
|
|
272
|
+
// Polling sequence: 10s → 20s → 30s → 30s...
|
|
273
|
+
const delay = Math.min(
|
|
274
|
+
BASE_POLLING_INTERVAL * Math.pow(2, state.attempts),
|
|
275
|
+
MAX_POLLING_INTERVAL
|
|
276
|
+
);
|
|
278
277
|
|
|
279
278
|
// Schedule the next poll
|
|
280
279
|
const timeoutId = setTimeout(() => executePoll(tenantId), delay);
|
|
@@ -304,6 +303,7 @@ async function executePoll(tenantId: string): Promise<void> {
|
|
|
304
303
|
|
|
305
304
|
// Check if analysis is complete
|
|
306
305
|
if (data.status === 'complete') {
|
|
306
|
+
updateTenantState(tenantId, { isLoading: false });
|
|
307
307
|
stopPolling(tenantId);
|
|
308
308
|
return;
|
|
309
309
|
}
|
|
@@ -329,7 +329,6 @@ async function executePoll(tenantId: string): Promise<void> {
|
|
|
329
329
|
error instanceof Error ? error.message : 'Unknown polling error';
|
|
330
330
|
updateTenantState(tenantId, {
|
|
331
331
|
error: `Polling error (attempt ${state.attempts}/${MAX_POLLING_ATTEMPTS}): ${errorMessage}`,
|
|
332
|
-
lastFetched: Date.now(),
|
|
333
332
|
});
|
|
334
333
|
|
|
335
334
|
// Check if we should stop polling due to consecutive errors
|
|
@@ -344,7 +343,7 @@ async function executePoll(tenantId: string): Promise<void> {
|
|
|
344
343
|
return;
|
|
345
344
|
}
|
|
346
345
|
|
|
347
|
-
// Schedule next poll
|
|
346
|
+
// Schedule next poll (will use exponential backoff due to increased consecutiveErrors)
|
|
348
347
|
scheduleNextPoll(tenantId);
|
|
349
348
|
}
|
|
350
349
|
}
|
|
@@ -357,8 +356,7 @@ function handlePollingFailure(tenantId: string, reason: string): void {
|
|
|
357
356
|
// Update tenant state with final error
|
|
358
357
|
updateTenantState(tenantId, {
|
|
359
358
|
isLoading: false,
|
|
360
|
-
error: `Orphan analysis
|
|
361
|
-
lastFetched: Date.now(),
|
|
359
|
+
error: `Orphan analysis failed: ${reason}. Please try refreshing the page.`,
|
|
362
360
|
});
|
|
363
361
|
|
|
364
362
|
// Clean up polling state
|