ai-progress-controls 0.1.0
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/LICENSE +21 -0
- package/README.md +823 -0
- package/dist/ai-progress-controls.es.js +7191 -0
- package/dist/ai-progress-controls.es.js.map +1 -0
- package/dist/ai-progress-controls.umd.js +2 -0
- package/dist/ai-progress-controls.umd.js.map +1 -0
- package/dist/index.d.ts +2212 -0
- package/package.json +105 -0
- package/src/__tests__/setup.ts +93 -0
- package/src/core/base/AIControl.ts +230 -0
- package/src/core/base/index.ts +3 -0
- package/src/core/base/types.ts +77 -0
- package/src/core/base/utils.ts +168 -0
- package/src/core/batch-progress/BatchProgress.test.ts +458 -0
- package/src/core/batch-progress/BatchProgress.ts +760 -0
- package/src/core/batch-progress/index.ts +14 -0
- package/src/core/batch-progress/styles.ts +480 -0
- package/src/core/batch-progress/types.ts +169 -0
- package/src/core/model-loader/ModelLoader.test.ts +311 -0
- package/src/core/model-loader/ModelLoader.ts +673 -0
- package/src/core/model-loader/index.ts +2 -0
- package/src/core/model-loader/styles.ts +496 -0
- package/src/core/model-loader/types.ts +127 -0
- package/src/core/parameter-panel/ParameterPanel.test.ts +856 -0
- package/src/core/parameter-panel/ParameterPanel.ts +877 -0
- package/src/core/parameter-panel/index.ts +14 -0
- package/src/core/parameter-panel/styles.ts +323 -0
- package/src/core/parameter-panel/types.ts +278 -0
- package/src/core/parameter-slider/ParameterSlider.test.ts +299 -0
- package/src/core/parameter-slider/ParameterSlider.ts +653 -0
- package/src/core/parameter-slider/index.ts +8 -0
- package/src/core/parameter-slider/styles.ts +493 -0
- package/src/core/parameter-slider/types.ts +107 -0
- package/src/core/queue-progress/QueueProgress.test.ts +344 -0
- package/src/core/queue-progress/QueueProgress.ts +563 -0
- package/src/core/queue-progress/index.ts +5 -0
- package/src/core/queue-progress/styles.ts +469 -0
- package/src/core/queue-progress/types.ts +130 -0
- package/src/core/retry-progress/RetryProgress.test.ts +397 -0
- package/src/core/retry-progress/RetryProgress.ts +957 -0
- package/src/core/retry-progress/index.ts +6 -0
- package/src/core/retry-progress/styles.ts +530 -0
- package/src/core/retry-progress/types.ts +176 -0
- package/src/core/stream-progress/StreamProgress.test.ts +531 -0
- package/src/core/stream-progress/StreamProgress.ts +517 -0
- package/src/core/stream-progress/index.ts +2 -0
- package/src/core/stream-progress/styles.ts +349 -0
- package/src/core/stream-progress/types.ts +82 -0
- package/src/index.ts +19 -0
|
@@ -0,0 +1,957 @@
|
|
|
1
|
+
import { AIControl } from '../base/AIControl';
|
|
2
|
+
import { formatTime } from '../base/utils';
|
|
3
|
+
import type {
|
|
4
|
+
RetryProgressConfig,
|
|
5
|
+
RetryProgressState,
|
|
6
|
+
RetryAttemptUpdate,
|
|
7
|
+
RetryStatus,
|
|
8
|
+
} from './types';
|
|
9
|
+
import { styles } from './styles';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* RetryProgress Component
|
|
13
|
+
*
|
|
14
|
+
* Displays retry progress with exponential backoff, attempt tracking, and error handling.
|
|
15
|
+
* Perfect for handling transient failures in API calls, network requests, or AI operations.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```typescript
|
|
19
|
+
* // Create the component
|
|
20
|
+
* const retry = new RetryProgress({
|
|
21
|
+
* maxAttempts: 5,
|
|
22
|
+
* initialDelay: 1000,
|
|
23
|
+
* strategy: 'exponential',
|
|
24
|
+
* allowManualRetry: true,
|
|
25
|
+
* });
|
|
26
|
+
*
|
|
27
|
+
* document.body.appendChild(retry);
|
|
28
|
+
*
|
|
29
|
+
* // Start first attempt
|
|
30
|
+
* retry.attempt('Connecting to API...');
|
|
31
|
+
*
|
|
32
|
+
* // If it fails, start waiting for retry
|
|
33
|
+
* retry.waitForRetry({
|
|
34
|
+
* attempt: 2,
|
|
35
|
+
* error: new Error('Connection timeout'),
|
|
36
|
+
* });
|
|
37
|
+
*
|
|
38
|
+
* // On success
|
|
39
|
+
* retry.success('Connected successfully!');
|
|
40
|
+
*
|
|
41
|
+
* // Listen to events
|
|
42
|
+
* retry.addEventListener('retryattempt', (e) => {
|
|
43
|
+
* console.log('Attempting:', e.detail);
|
|
44
|
+
* });
|
|
45
|
+
* ```
|
|
46
|
+
*
|
|
47
|
+
* @fires retryattempt - Fired when a retry attempt starts
|
|
48
|
+
* @fires retrywaiting - Fired when waiting for next retry
|
|
49
|
+
* @fires retrysuccess - Fired when operation succeeds
|
|
50
|
+
* @fires retryfailure - Fired when max attempts reached
|
|
51
|
+
* @fires retrycancel - Fired when retry is cancelled
|
|
52
|
+
* @fires manualretry - Fired when user manually triggers retry
|
|
53
|
+
*/
|
|
54
|
+
export class RetryProgress extends AIControl {
|
|
55
|
+
protected override config: Required<RetryProgressConfig>;
|
|
56
|
+
private state: RetryProgressState;
|
|
57
|
+
private waitTimer?: ReturnType<typeof setTimeout>;
|
|
58
|
+
private elapsedTimer?: ReturnType<typeof setInterval>;
|
|
59
|
+
private progressTimer?: ReturnType<typeof setInterval>;
|
|
60
|
+
|
|
61
|
+
constructor(config: RetryProgressConfig = {}) {
|
|
62
|
+
super(config);
|
|
63
|
+
|
|
64
|
+
this.config = {
|
|
65
|
+
attempt: config.attempt ?? 1,
|
|
66
|
+
maxAttempts: config.maxAttempts ?? 3,
|
|
67
|
+
initialDelay: config.initialDelay ?? 1000,
|
|
68
|
+
maxDelay: config.maxDelay ?? 30000,
|
|
69
|
+
backoffMultiplier: config.backoffMultiplier ?? 2,
|
|
70
|
+
strategy: config.strategy ?? 'exponential',
|
|
71
|
+
message: config.message ?? 'Retrying operation...',
|
|
72
|
+
showAttemptCount: config.showAttemptCount ?? true,
|
|
73
|
+
showNextRetry: config.showNextRetry ?? true,
|
|
74
|
+
showProgressBar: config.showProgressBar ?? true,
|
|
75
|
+
showElapsedTime: config.showElapsedTime ?? true,
|
|
76
|
+
allowManualRetry: config.allowManualRetry ?? false,
|
|
77
|
+
allowCancel: config.allowCancel ?? true,
|
|
78
|
+
animate: config.animate ?? true,
|
|
79
|
+
className: config.className ?? '',
|
|
80
|
+
ariaLabel: config.ariaLabel ?? 'Retry Progress',
|
|
81
|
+
cursorFeedback: config.cursorFeedback ?? true,
|
|
82
|
+
debug: config.debug ?? false,
|
|
83
|
+
disabled: config.disabled ?? false,
|
|
84
|
+
size: config.size ?? 'default',
|
|
85
|
+
variant: config.variant ?? 'default',
|
|
86
|
+
animation: config.animation ?? 'none',
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
this.state = {
|
|
90
|
+
status: 'idle',
|
|
91
|
+
attempt: this.config.attempt,
|
|
92
|
+
maxAttempts: this.config.maxAttempts,
|
|
93
|
+
currentDelay: this.config.initialDelay,
|
|
94
|
+
nextRetryTime: 0,
|
|
95
|
+
startTime: 0,
|
|
96
|
+
elapsedTime: 0,
|
|
97
|
+
message: this.config.message,
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
this.attachShadow({ mode: 'open' });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Read max-attempts attribute
|
|
105
|
+
*/
|
|
106
|
+
private _readMaxAttemptsAttribute(): void {
|
|
107
|
+
if (!this.hasAttribute('max-attempts')) return;
|
|
108
|
+
|
|
109
|
+
const val = Number.parseInt(this.getAttribute('max-attempts') || '', 10);
|
|
110
|
+
if (!Number.isNaN(val)) {
|
|
111
|
+
this.config.maxAttempts = val;
|
|
112
|
+
this.state.maxAttempts = val;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Read initial-delay attribute
|
|
118
|
+
*/
|
|
119
|
+
private _readInitialDelayAttribute(): void {
|
|
120
|
+
if (!this.hasAttribute('initial-delay')) return;
|
|
121
|
+
|
|
122
|
+
const val = Number.parseInt(this.getAttribute('initial-delay') || '', 10);
|
|
123
|
+
if (!Number.isNaN(val)) {
|
|
124
|
+
this.config.initialDelay = val;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Read max-delay attribute
|
|
130
|
+
*/
|
|
131
|
+
private _readMaxDelayAttribute(): void {
|
|
132
|
+
if (!this.hasAttribute('max-delay')) return;
|
|
133
|
+
|
|
134
|
+
const val = Number.parseInt(this.getAttribute('max-delay') || '', 10);
|
|
135
|
+
if (!Number.isNaN(val)) {
|
|
136
|
+
this.config.maxDelay = val;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Read backoff-multiplier attribute
|
|
142
|
+
*/
|
|
143
|
+
private _readBackoffMultiplierAttribute(): void {
|
|
144
|
+
if (!this.hasAttribute('backoff-multiplier')) return;
|
|
145
|
+
|
|
146
|
+
const val = Number.parseFloat(this.getAttribute('backoff-multiplier') || '');
|
|
147
|
+
if (!Number.isNaN(val)) {
|
|
148
|
+
this.config.backoffMultiplier = val;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Read strategy attribute
|
|
154
|
+
*/
|
|
155
|
+
private _readStrategyAttribute(): void {
|
|
156
|
+
if (!this.hasAttribute('strategy')) return;
|
|
157
|
+
|
|
158
|
+
const strategy = this.getAttribute('strategy') as RetryStatus;
|
|
159
|
+
if (['exponential', 'linear', 'fixed', 'fibonacci'].includes(strategy)) {
|
|
160
|
+
this.config.strategy = strategy as any;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Read boolean attributes
|
|
166
|
+
*/
|
|
167
|
+
private _readBooleanAttributes(): void {
|
|
168
|
+
if (this.hasAttribute('allow-manual-retry')) {
|
|
169
|
+
this.config.allowManualRetry = this.getAttribute('allow-manual-retry') === 'true';
|
|
170
|
+
}
|
|
171
|
+
if (this.hasAttribute('allow-cancel')) {
|
|
172
|
+
this.config.allowCancel = this.getAttribute('allow-cancel') === 'true';
|
|
173
|
+
}
|
|
174
|
+
if (this.hasAttribute('show-attempt-count')) {
|
|
175
|
+
this.config.showAttemptCount = this.getAttribute('show-attempt-count') === 'true';
|
|
176
|
+
}
|
|
177
|
+
if (this.hasAttribute('show-progress-bar')) {
|
|
178
|
+
this.config.showProgressBar = this.getAttribute('show-progress-bar') === 'true';
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Connected callback - read initial attributes
|
|
184
|
+
*/
|
|
185
|
+
override connectedCallback(): void {
|
|
186
|
+
super.connectedCallback();
|
|
187
|
+
|
|
188
|
+
this._readMaxAttemptsAttribute();
|
|
189
|
+
this._readInitialDelayAttribute();
|
|
190
|
+
this._readMaxDelayAttribute();
|
|
191
|
+
this._readBackoffMultiplierAttribute();
|
|
192
|
+
this._readStrategyAttribute();
|
|
193
|
+
this._readBooleanAttributes();
|
|
194
|
+
|
|
195
|
+
this.render();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Calculate delay for retry attempt based on strategy
|
|
200
|
+
*/
|
|
201
|
+
private calculateDelay(attempt: number): number {
|
|
202
|
+
const { strategy, initialDelay, backoffMultiplier, maxDelay } = this.config;
|
|
203
|
+
|
|
204
|
+
let delay: number;
|
|
205
|
+
|
|
206
|
+
switch (strategy) {
|
|
207
|
+
case 'exponential':
|
|
208
|
+
delay = initialDelay * Math.pow(backoffMultiplier, attempt - 1);
|
|
209
|
+
break;
|
|
210
|
+
|
|
211
|
+
case 'linear':
|
|
212
|
+
delay = initialDelay * attempt;
|
|
213
|
+
break;
|
|
214
|
+
|
|
215
|
+
case 'fibonacci':
|
|
216
|
+
delay = initialDelay * this.fibonacci(attempt);
|
|
217
|
+
break;
|
|
218
|
+
|
|
219
|
+
case 'fixed':
|
|
220
|
+
default:
|
|
221
|
+
delay = initialDelay;
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return Math.min(delay, maxDelay);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Calculate fibonacci number
|
|
230
|
+
*/
|
|
231
|
+
private fibonacci(n: number): number {
|
|
232
|
+
if (n <= 1) return 1;
|
|
233
|
+
let a = 1,
|
|
234
|
+
b = 1;
|
|
235
|
+
for (let i = 2; i < n; i++) {
|
|
236
|
+
[a, b] = [b, a + b];
|
|
237
|
+
}
|
|
238
|
+
return b;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Start a retry attempt
|
|
243
|
+
*/
|
|
244
|
+
public attempt(message?: string): void {
|
|
245
|
+
this.stopTimers();
|
|
246
|
+
|
|
247
|
+
const attemptMessage = message || `Attempt ${this.state.attempt} of ${this.state.maxAttempts}`;
|
|
248
|
+
|
|
249
|
+
this.setState({
|
|
250
|
+
status: 'attempting',
|
|
251
|
+
message: attemptMessage,
|
|
252
|
+
startTime: this.state.startTime || Date.now(),
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
this.startElapsedTimer();
|
|
256
|
+
|
|
257
|
+
this.dispatchEvent(
|
|
258
|
+
new CustomEvent('retryattempt', {
|
|
259
|
+
detail: {
|
|
260
|
+
attempt: this.state.attempt,
|
|
261
|
+
maxAttempts: this.state.maxAttempts,
|
|
262
|
+
message: attemptMessage,
|
|
263
|
+
timestamp: Date.now(),
|
|
264
|
+
},
|
|
265
|
+
})
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
this.log(`Retry attempt ${this.state.attempt}/${this.state.maxAttempts}: ${attemptMessage}`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Wait for next retry with optional error
|
|
273
|
+
*/
|
|
274
|
+
public waitForRetry(update: RetryAttemptUpdate = {}): void {
|
|
275
|
+
const nextAttempt = update.attempt ?? this.state.attempt + 1;
|
|
276
|
+
|
|
277
|
+
if (nextAttempt > this.state.maxAttempts) {
|
|
278
|
+
this.failure(update.error);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const delay = update.delay ?? this.calculateDelay(nextAttempt);
|
|
283
|
+
const nextRetryTime = Date.now() + delay;
|
|
284
|
+
|
|
285
|
+
this.setState({
|
|
286
|
+
status: 'waiting',
|
|
287
|
+
attempt: nextAttempt,
|
|
288
|
+
currentDelay: delay,
|
|
289
|
+
nextRetryTime,
|
|
290
|
+
message: update.message || `Retrying in ${formatTime(Math.ceil(delay / 1000))}...`,
|
|
291
|
+
errorMessage: update.error?.message,
|
|
292
|
+
lastError: update.error,
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
this.startWaitTimer(delay);
|
|
296
|
+
this.startProgressTimer(delay);
|
|
297
|
+
|
|
298
|
+
this.dispatchEvent(
|
|
299
|
+
new CustomEvent('retrywaiting', {
|
|
300
|
+
detail: {
|
|
301
|
+
attempt: nextAttempt,
|
|
302
|
+
delay,
|
|
303
|
+
nextRetryTime,
|
|
304
|
+
strategy: this.config.strategy,
|
|
305
|
+
timestamp: Date.now(),
|
|
306
|
+
},
|
|
307
|
+
})
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
this.log(`Waiting ${delay}ms before retry ${nextAttempt}/${this.state.maxAttempts}`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Mark operation as successful
|
|
315
|
+
*/
|
|
316
|
+
public success(message?: string): void {
|
|
317
|
+
this.stopTimers();
|
|
318
|
+
|
|
319
|
+
const successMessage = message || 'Operation successful!';
|
|
320
|
+
|
|
321
|
+
this.setState({
|
|
322
|
+
status: 'success',
|
|
323
|
+
message: successMessage,
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
this.dispatchEvent(
|
|
327
|
+
new CustomEvent('retrysuccess', {
|
|
328
|
+
detail: {
|
|
329
|
+
attempt: this.state.attempt,
|
|
330
|
+
totalAttempts: this.state.attempt,
|
|
331
|
+
elapsedTime: this.state.elapsedTime,
|
|
332
|
+
message: successMessage,
|
|
333
|
+
timestamp: Date.now(),
|
|
334
|
+
},
|
|
335
|
+
})
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
this.log(`Success after ${this.state.attempt} attempts (${this.state.elapsedTime}ms)`);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Mark operation as failed (max attempts reached)
|
|
343
|
+
*/
|
|
344
|
+
public failure(error?: Error): void {
|
|
345
|
+
this.stopTimers();
|
|
346
|
+
|
|
347
|
+
this.setState({
|
|
348
|
+
status: 'failed',
|
|
349
|
+
message: 'Maximum retry attempts reached',
|
|
350
|
+
errorMessage: error?.message || 'Operation failed',
|
|
351
|
+
lastError: error,
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
this.dispatchEvent(
|
|
355
|
+
new CustomEvent('retryfailure', {
|
|
356
|
+
detail: {
|
|
357
|
+
totalAttempts: this.state.attempt,
|
|
358
|
+
lastError: error,
|
|
359
|
+
elapsedTime: this.state.elapsedTime,
|
|
360
|
+
timestamp: Date.now(),
|
|
361
|
+
},
|
|
362
|
+
})
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
this.logError('Retry failed', error || new Error('Maximum attempts reached'));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Cancel retry operation
|
|
370
|
+
*/
|
|
371
|
+
public cancel(reason?: string): void {
|
|
372
|
+
this.stopTimers();
|
|
373
|
+
|
|
374
|
+
this.setState({
|
|
375
|
+
status: 'cancelled',
|
|
376
|
+
message: reason || 'Operation cancelled',
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
this.dispatchEvent(
|
|
380
|
+
new CustomEvent('retrycancel', {
|
|
381
|
+
detail: {
|
|
382
|
+
attempt: this.state.attempt,
|
|
383
|
+
reason,
|
|
384
|
+
timestamp: Date.now(),
|
|
385
|
+
},
|
|
386
|
+
})
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
this.log(`Retry cancelled: ${reason || 'User cancelled'}`);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Reset to initial state
|
|
394
|
+
*/
|
|
395
|
+
public reset(): void {
|
|
396
|
+
this.stopTimers();
|
|
397
|
+
|
|
398
|
+
this.state = {
|
|
399
|
+
status: 'idle',
|
|
400
|
+
attempt: this.config.attempt,
|
|
401
|
+
maxAttempts: this.config.maxAttempts,
|
|
402
|
+
currentDelay: this.config.initialDelay,
|
|
403
|
+
nextRetryTime: 0,
|
|
404
|
+
startTime: 0,
|
|
405
|
+
elapsedTime: 0,
|
|
406
|
+
message: this.config.message,
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
this.render();
|
|
410
|
+
this.log('Reset to initial state');
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Get current attempt number
|
|
415
|
+
*/
|
|
416
|
+
public getAttempt(): number {
|
|
417
|
+
return this.state.attempt;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Get current status
|
|
422
|
+
*/
|
|
423
|
+
public getStatus(): RetryStatus {
|
|
424
|
+
return this.state.status;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Get time until next retry (ms)
|
|
429
|
+
*/
|
|
430
|
+
public getTimeUntilRetry(): number {
|
|
431
|
+
if (this.state.status !== 'waiting') return 0;
|
|
432
|
+
return Math.max(0, this.state.nextRetryTime - Date.now());
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Start elapsed time timer
|
|
437
|
+
*/
|
|
438
|
+
private startElapsedTimer(): void {
|
|
439
|
+
this.elapsedTimer = globalThis.setInterval(() => {
|
|
440
|
+
if (this.state.startTime > 0) {
|
|
441
|
+
this.state.elapsedTime = Date.now() - this.state.startTime;
|
|
442
|
+
this.render();
|
|
443
|
+
}
|
|
444
|
+
}, 1000);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Start wait timer for automatic retry
|
|
449
|
+
*/
|
|
450
|
+
private startWaitTimer(delay: number): void {
|
|
451
|
+
this.waitTimer = globalThis.setTimeout(() => {
|
|
452
|
+
if (this.state.status === 'waiting') {
|
|
453
|
+
this.attempt();
|
|
454
|
+
}
|
|
455
|
+
}, delay);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Start progress bar timer
|
|
460
|
+
*/
|
|
461
|
+
private startProgressTimer(_totalDelay: number): void {
|
|
462
|
+
this.progressTimer = globalThis.setInterval(() => {
|
|
463
|
+
if (this.state.status === 'waiting') {
|
|
464
|
+
const remaining = Math.max(0, this.state.nextRetryTime - Date.now());
|
|
465
|
+
this.state.message = `Retrying in ${formatTime(Math.ceil(remaining / 1000))}...`;
|
|
466
|
+
this.render();
|
|
467
|
+
}
|
|
468
|
+
}, 100);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Stop all timers
|
|
473
|
+
*/
|
|
474
|
+
private stopTimers(): void {
|
|
475
|
+
if (this.waitTimer) {
|
|
476
|
+
globalThis.clearTimeout(this.waitTimer);
|
|
477
|
+
this.waitTimer = undefined;
|
|
478
|
+
}
|
|
479
|
+
if (this.elapsedTimer) {
|
|
480
|
+
globalThis.clearInterval(this.elapsedTimer);
|
|
481
|
+
this.elapsedTimer = undefined;
|
|
482
|
+
}
|
|
483
|
+
if (this.progressTimer) {
|
|
484
|
+
globalThis.clearInterval(this.progressTimer);
|
|
485
|
+
this.progressTimer = undefined;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Handle manual retry button click
|
|
491
|
+
*/
|
|
492
|
+
private handleManualRetry(): void {
|
|
493
|
+
if (this.state.status !== 'waiting' && this.state.status !== 'failed') return;
|
|
494
|
+
|
|
495
|
+
this.dispatchEvent(
|
|
496
|
+
new CustomEvent('manualretry', {
|
|
497
|
+
detail: {
|
|
498
|
+
attempt: this.state.attempt,
|
|
499
|
+
timestamp: Date.now(),
|
|
500
|
+
},
|
|
501
|
+
})
|
|
502
|
+
);
|
|
503
|
+
|
|
504
|
+
this.attempt();
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Handle cancel button click
|
|
509
|
+
*/
|
|
510
|
+
private handleCancel(): void {
|
|
511
|
+
this.cancel('User cancelled operation');
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Update state and re-render
|
|
516
|
+
*/
|
|
517
|
+
private setState(update: Partial<RetryProgressState>): void {
|
|
518
|
+
Object.assign(this.state, update);
|
|
519
|
+
this.render();
|
|
520
|
+
this.updateCursor();
|
|
521
|
+
|
|
522
|
+
// Update ARIA attributes
|
|
523
|
+
const progress = ((this.state.attempt / this.state.maxAttempts) * 100).toFixed(0);
|
|
524
|
+
this.setAttribute('aria-valuenow', this.state.attempt.toString());
|
|
525
|
+
this.setAttribute('aria-valuemax', this.state.maxAttempts.toString());
|
|
526
|
+
this.setAttribute(
|
|
527
|
+
'aria-valuetext',
|
|
528
|
+
`Attempt ${this.state.attempt} of ${this.state.maxAttempts}, ${progress}% complete`
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Update cursor based on retry state
|
|
534
|
+
*/
|
|
535
|
+
private updateCursor(): void {
|
|
536
|
+
if (!this.config.cursorFeedback) return;
|
|
537
|
+
|
|
538
|
+
if (this.state.status === 'attempting') {
|
|
539
|
+
this.style.cursor = 'progress';
|
|
540
|
+
} else if (this.state.status === 'waiting') {
|
|
541
|
+
this.style.cursor = 'wait';
|
|
542
|
+
} else if (this.state.status === 'failed' || this.state.status === 'cancelled') {
|
|
543
|
+
this.style.cursor = 'not-allowed';
|
|
544
|
+
} else {
|
|
545
|
+
this.style.cursor = 'default';
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Get status icon
|
|
551
|
+
*/
|
|
552
|
+
private getStatusIcon(): string {
|
|
553
|
+
switch (this.state.status) {
|
|
554
|
+
case 'attempting':
|
|
555
|
+
return '🔄';
|
|
556
|
+
case 'waiting':
|
|
557
|
+
return '⏳';
|
|
558
|
+
case 'success':
|
|
559
|
+
return '✅';
|
|
560
|
+
case 'failed':
|
|
561
|
+
return '❌';
|
|
562
|
+
case 'cancelled':
|
|
563
|
+
return '🚫';
|
|
564
|
+
default:
|
|
565
|
+
return '⏸️';
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Get status text
|
|
571
|
+
*/
|
|
572
|
+
private getStatusText(): string {
|
|
573
|
+
switch (this.state.status) {
|
|
574
|
+
case 'attempting':
|
|
575
|
+
return 'Attempting';
|
|
576
|
+
case 'waiting':
|
|
577
|
+
return 'Waiting';
|
|
578
|
+
case 'success':
|
|
579
|
+
return 'Success';
|
|
580
|
+
case 'failed':
|
|
581
|
+
return 'Failed';
|
|
582
|
+
case 'cancelled':
|
|
583
|
+
return 'Cancelled';
|
|
584
|
+
default:
|
|
585
|
+
return 'Idle';
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Calculate progress percentage
|
|
591
|
+
*/
|
|
592
|
+
private getProgressPercentage(): number {
|
|
593
|
+
if (this.state.status !== 'waiting') return 0;
|
|
594
|
+
|
|
595
|
+
const elapsed = Date.now() - (this.state.nextRetryTime - this.state.currentDelay);
|
|
596
|
+
return Math.min(100, (elapsed / this.state.currentDelay) * 100);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Render success message
|
|
601
|
+
*/
|
|
602
|
+
private renderSuccessMessage(status: RetryStatus, attempt: number): string {
|
|
603
|
+
if (status !== 'success') return '';
|
|
604
|
+
|
|
605
|
+
const attemptText = attempt > 1 ? 's' : '';
|
|
606
|
+
return `
|
|
607
|
+
<div class="success-message">
|
|
608
|
+
<div class="success-icon">🎉</div>
|
|
609
|
+
<div class="success-text">Success!</div>
|
|
610
|
+
<div class="success-details">Completed in ${attempt} attempt${attemptText}</div>
|
|
611
|
+
</div>
|
|
612
|
+
`;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Render action buttons
|
|
617
|
+
*/
|
|
618
|
+
private renderActions(
|
|
619
|
+
status: RetryStatus,
|
|
620
|
+
allowManualRetry: boolean,
|
|
621
|
+
allowCancel: boolean,
|
|
622
|
+
disabled: boolean
|
|
623
|
+
): string {
|
|
624
|
+
if (status === 'success' || (!allowManualRetry && !allowCancel)) return '';
|
|
625
|
+
|
|
626
|
+
const showRetryButton = allowManualRetry && (status === 'waiting' || status === 'failed');
|
|
627
|
+
const showCancelButton = allowCancel && status !== 'cancelled' && status !== 'failed';
|
|
628
|
+
|
|
629
|
+
if (!showRetryButton && !showCancelButton) return '';
|
|
630
|
+
|
|
631
|
+
const retryButtonText = status === 'failed' ? 'Try Again' : 'Retry Now';
|
|
632
|
+
const disabledAttr = disabled ? 'disabled' : '';
|
|
633
|
+
|
|
634
|
+
let buttonsHtml = '';
|
|
635
|
+
if (showRetryButton) {
|
|
636
|
+
buttonsHtml += `
|
|
637
|
+
<button class="retry-button" id="manual-retry" ${disabledAttr}>
|
|
638
|
+
${retryButtonText}
|
|
639
|
+
</button>
|
|
640
|
+
`;
|
|
641
|
+
}
|
|
642
|
+
if (showCancelButton) {
|
|
643
|
+
buttonsHtml += `
|
|
644
|
+
<button class="cancel-button" id="cancel-btn" ${disabledAttr}>
|
|
645
|
+
Cancel
|
|
646
|
+
</button>
|
|
647
|
+
`;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
return `
|
|
651
|
+
<div class="actions">
|
|
652
|
+
${buttonsHtml}
|
|
653
|
+
</div>
|
|
654
|
+
`;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Sync config attributes to host element
|
|
659
|
+
*/
|
|
660
|
+
private _syncAttributes(): void {
|
|
661
|
+
if (this.config.size && this.getAttribute('size') !== this.config.size) {
|
|
662
|
+
this.setAttribute('size', this.config.size);
|
|
663
|
+
}
|
|
664
|
+
if (this.config.variant && this.getAttribute('variant') !== this.config.variant) {
|
|
665
|
+
this.setAttribute('variant', this.config.variant);
|
|
666
|
+
}
|
|
667
|
+
if (this.config.animation && this.getAttribute('animation') !== this.config.animation) {
|
|
668
|
+
this.setAttribute('animation', this.config.animation);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Get attempt counter HTML
|
|
674
|
+
*/
|
|
675
|
+
private _getAttemptCounterHtml(
|
|
676
|
+
showAttemptCount: boolean,
|
|
677
|
+
attempt: number,
|
|
678
|
+
maxAttempts: number,
|
|
679
|
+
status: RetryStatus
|
|
680
|
+
): string {
|
|
681
|
+
if (!showAttemptCount) {
|
|
682
|
+
return '';
|
|
683
|
+
}
|
|
684
|
+
return `
|
|
685
|
+
<div class="attempt-counter">
|
|
686
|
+
<div class="attempt-number ${status}">${attempt}</div>
|
|
687
|
+
<div class="attempt-label">of ${maxAttempts} attempts</div>
|
|
688
|
+
</div>
|
|
689
|
+
`;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Get metrics grid HTML
|
|
694
|
+
*/
|
|
695
|
+
private _getMetricsGridHtml(
|
|
696
|
+
showNextRetry: boolean,
|
|
697
|
+
showElapsedTime: boolean,
|
|
698
|
+
status: RetryStatus,
|
|
699
|
+
remainingTime: number,
|
|
700
|
+
elapsedTime: number,
|
|
701
|
+
strategy: string
|
|
702
|
+
): string {
|
|
703
|
+
const nextRetryHtml =
|
|
704
|
+
showNextRetry && status === 'waiting'
|
|
705
|
+
? `
|
|
706
|
+
<div class="metric">
|
|
707
|
+
<div class="metric-value">${formatTime(Math.ceil(remainingTime / 1000))}</div>
|
|
708
|
+
<div class="metric-label">Next Retry</div>
|
|
709
|
+
</div>
|
|
710
|
+
`
|
|
711
|
+
: '';
|
|
712
|
+
|
|
713
|
+
const elapsedHtml =
|
|
714
|
+
showElapsedTime && elapsedTime > 0
|
|
715
|
+
? `
|
|
716
|
+
<div class="metric">
|
|
717
|
+
<div class="metric-value">${formatTime(Math.ceil(elapsedTime / 1000))}</div>
|
|
718
|
+
<div class="metric-label">Elapsed</div>
|
|
719
|
+
</div>
|
|
720
|
+
`
|
|
721
|
+
: '';
|
|
722
|
+
|
|
723
|
+
return `
|
|
724
|
+
<div class="metrics-grid">
|
|
725
|
+
${nextRetryHtml}
|
|
726
|
+
${elapsedHtml}
|
|
727
|
+
<div class="metric">
|
|
728
|
+
<div class="metric-value">${strategy}</div>
|
|
729
|
+
<div class="metric-label">Strategy</div>
|
|
730
|
+
</div>
|
|
731
|
+
</div>
|
|
732
|
+
`;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Get progress bar HTML
|
|
737
|
+
*/
|
|
738
|
+
private _getProgressBarHtml(
|
|
739
|
+
showProgressBar: boolean,
|
|
740
|
+
status: RetryStatus,
|
|
741
|
+
progressPercentage: number
|
|
742
|
+
): string {
|
|
743
|
+
if (!showProgressBar || status !== 'waiting') {
|
|
744
|
+
return '';
|
|
745
|
+
}
|
|
746
|
+
return `
|
|
747
|
+
<div class="progress-bar-container">
|
|
748
|
+
<div class="progress-label">Time until next attempt</div>
|
|
749
|
+
<div class="progress-bar">
|
|
750
|
+
<div class="progress-fill" style="width: ${progressPercentage}%"></div>
|
|
751
|
+
</div>
|
|
752
|
+
</div>
|
|
753
|
+
`;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Get error display HTML
|
|
758
|
+
*/
|
|
759
|
+
private _getErrorDisplayHtml(errorMessage: string | undefined, status: RetryStatus): string {
|
|
760
|
+
if (!errorMessage || (status !== 'waiting' && status !== 'failed')) {
|
|
761
|
+
return '';
|
|
762
|
+
}
|
|
763
|
+
return `
|
|
764
|
+
<div class="error-display">
|
|
765
|
+
<div class="error-title">Last Error</div>
|
|
766
|
+
<div class="error-message">${errorMessage}</div>
|
|
767
|
+
</div>
|
|
768
|
+
`;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Attach event listeners to rendered elements
|
|
773
|
+
*/
|
|
774
|
+
private _attachEventListeners(allowManualRetry: boolean, allowCancel: boolean): void {
|
|
775
|
+
if (allowManualRetry) {
|
|
776
|
+
const retryBtn = this.shadowRoot?.getElementById('manual-retry');
|
|
777
|
+
retryBtn?.addEventListener('click', () => this.handleManualRetry());
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
if (allowCancel) {
|
|
781
|
+
const cancelBtn = this.shadowRoot?.getElementById('cancel-btn');
|
|
782
|
+
cancelBtn?.addEventListener('click', () => this.handleCancel());
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Render component
|
|
788
|
+
*/
|
|
789
|
+
protected override render(): void {
|
|
790
|
+
if (!this.shadowRoot) return;
|
|
791
|
+
|
|
792
|
+
this._syncAttributes();
|
|
793
|
+
|
|
794
|
+
const { status, attempt, maxAttempts, message, errorMessage, elapsedTime } = this.state;
|
|
795
|
+
const {
|
|
796
|
+
showAttemptCount,
|
|
797
|
+
showNextRetry,
|
|
798
|
+
showProgressBar,
|
|
799
|
+
showElapsedTime,
|
|
800
|
+
allowManualRetry,
|
|
801
|
+
allowCancel,
|
|
802
|
+
disabled,
|
|
803
|
+
} = this.config;
|
|
804
|
+
|
|
805
|
+
const remainingTime = this.getTimeUntilRetry();
|
|
806
|
+
const progressPercentage = this.getProgressPercentage();
|
|
807
|
+
|
|
808
|
+
const attemptCounterHtml = this._getAttemptCounterHtml(
|
|
809
|
+
showAttemptCount,
|
|
810
|
+
attempt,
|
|
811
|
+
maxAttempts,
|
|
812
|
+
status
|
|
813
|
+
);
|
|
814
|
+
const metricsGridHtml = this._getMetricsGridHtml(
|
|
815
|
+
showNextRetry,
|
|
816
|
+
showElapsedTime,
|
|
817
|
+
status,
|
|
818
|
+
remainingTime,
|
|
819
|
+
elapsedTime,
|
|
820
|
+
this.config.strategy
|
|
821
|
+
);
|
|
822
|
+
const progressBarHtml = this._getProgressBarHtml(showProgressBar, status, progressPercentage);
|
|
823
|
+
const errorDisplayHtml = this._getErrorDisplayHtml(errorMessage, status);
|
|
824
|
+
const successMessageHtml = this.renderSuccessMessage(status, attempt);
|
|
825
|
+
const actionsHtml = this.renderActions(status, allowManualRetry, allowCancel, disabled);
|
|
826
|
+
|
|
827
|
+
this.shadowRoot.innerHTML = `
|
|
828
|
+
<style>${styles}</style>
|
|
829
|
+
<div class="retry-container ${disabled ? 'disabled' : ''}" role="status">
|
|
830
|
+
<div class="retry-header">
|
|
831
|
+
<div class="retry-icon ${status}">${this.getStatusIcon()}</div>
|
|
832
|
+
<div class="retry-info">
|
|
833
|
+
<h3 class="retry-title">${this.getStatusText()}</h3>
|
|
834
|
+
<p class="retry-message">${message}</p>
|
|
835
|
+
</div>
|
|
836
|
+
<span class="status-badge ${status}">${this.getStatusText()}</span>
|
|
837
|
+
</div>
|
|
838
|
+
|
|
839
|
+
${attemptCounterHtml}
|
|
840
|
+
${metricsGridHtml}
|
|
841
|
+
${progressBarHtml}
|
|
842
|
+
${errorDisplayHtml}
|
|
843
|
+
${successMessageHtml}
|
|
844
|
+
${actionsHtml}
|
|
845
|
+
</div>
|
|
846
|
+
`;
|
|
847
|
+
|
|
848
|
+
this._attachEventListeners(allowManualRetry, allowCancel);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* Cleanup on disconnect
|
|
853
|
+
*/
|
|
854
|
+
override disconnectedCallback(): void {
|
|
855
|
+
super.disconnectedCallback();
|
|
856
|
+
this.stopTimers();
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* Observed attributes
|
|
861
|
+
*/
|
|
862
|
+
static get observedAttributes(): string[] {
|
|
863
|
+
return [
|
|
864
|
+
'attempt',
|
|
865
|
+
'max-attempts',
|
|
866
|
+
'initial-delay',
|
|
867
|
+
'max-delay',
|
|
868
|
+
'backoff-multiplier',
|
|
869
|
+
'strategy',
|
|
870
|
+
'allow-manual-retry',
|
|
871
|
+
'allow-cancel',
|
|
872
|
+
'show-attempt-count',
|
|
873
|
+
'show-progress-bar',
|
|
874
|
+
'disabled',
|
|
875
|
+
'size',
|
|
876
|
+
'variant',
|
|
877
|
+
'animation',
|
|
878
|
+
];
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* Handle attribute changes
|
|
883
|
+
*/
|
|
884
|
+
protected override handleAttributeChange(
|
|
885
|
+
name: string,
|
|
886
|
+
_oldValue: string,
|
|
887
|
+
newValue: string
|
|
888
|
+
): void {
|
|
889
|
+
switch (name) {
|
|
890
|
+
case 'attempt': {
|
|
891
|
+
this.state.attempt = Number.parseInt(newValue, 10) || 1;
|
|
892
|
+
break;
|
|
893
|
+
}
|
|
894
|
+
case 'max-attempts': {
|
|
895
|
+
const maxAttempts = Number.parseInt(newValue, 10) || 3;
|
|
896
|
+
this.state.maxAttempts = maxAttempts;
|
|
897
|
+
this.config.maxAttempts = maxAttempts;
|
|
898
|
+
break;
|
|
899
|
+
}
|
|
900
|
+
case 'initial-delay': {
|
|
901
|
+
const initialDelay = Number.parseInt(newValue, 10);
|
|
902
|
+
if (Number.isNaN(initialDelay) === false) this.config.initialDelay = initialDelay;
|
|
903
|
+
break;
|
|
904
|
+
}
|
|
905
|
+
case 'max-delay': {
|
|
906
|
+
const maxDelay = Number.parseInt(newValue, 10);
|
|
907
|
+
if (Number.isNaN(maxDelay) === false) this.config.maxDelay = maxDelay;
|
|
908
|
+
break;
|
|
909
|
+
}
|
|
910
|
+
case 'backoff-multiplier': {
|
|
911
|
+
const multiplier = Number.parseFloat(newValue);
|
|
912
|
+
if (Number.isNaN(multiplier) === false) this.config.backoffMultiplier = multiplier;
|
|
913
|
+
break;
|
|
914
|
+
}
|
|
915
|
+
case 'strategy': {
|
|
916
|
+
if (['exponential', 'linear', 'fixed', 'fibonacci'].includes(newValue)) {
|
|
917
|
+
this.config.strategy = newValue as any;
|
|
918
|
+
}
|
|
919
|
+
break;
|
|
920
|
+
}
|
|
921
|
+
case 'allow-manual-retry': {
|
|
922
|
+
this.config.allowManualRetry = newValue === 'true';
|
|
923
|
+
break;
|
|
924
|
+
}
|
|
925
|
+
case 'allow-cancel': {
|
|
926
|
+
this.config.allowCancel = newValue === 'true';
|
|
927
|
+
break;
|
|
928
|
+
}
|
|
929
|
+
case 'show-attempt-count':
|
|
930
|
+
this.config.showAttemptCount = newValue === 'true';
|
|
931
|
+
break;
|
|
932
|
+
case 'show-progress-bar':
|
|
933
|
+
this.config.showProgressBar = newValue === 'true';
|
|
934
|
+
break;
|
|
935
|
+
case 'variant':
|
|
936
|
+
this.config.variant = newValue as any;
|
|
937
|
+
this.render();
|
|
938
|
+
break;
|
|
939
|
+
case 'animation':
|
|
940
|
+
this.config.animation = newValue as any;
|
|
941
|
+
this.render();
|
|
942
|
+
break;
|
|
943
|
+
case 'disabled':
|
|
944
|
+
this._disabled = newValue !== null;
|
|
945
|
+
break;
|
|
946
|
+
case 'size':
|
|
947
|
+
this.config.size = newValue as any;
|
|
948
|
+
break;
|
|
949
|
+
}
|
|
950
|
+
this.render();
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// Register custom element
|
|
955
|
+
if (!customElements.get('retry-progress')) {
|
|
956
|
+
customElements.define('retry-progress', RetryProgress);
|
|
957
|
+
}
|