alchemy-chimera 1.3.0-alpha.3 → 1.3.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.
@@ -0,0 +1,672 @@
1
+ /**
2
+ * The chimera-task-monitor element
3
+ * Displays live progress of a running task via WebSocket linkup
4
+ *
5
+ * @author Jelle De Loecker <jelle@elevenways.be>
6
+ * @since 1.3.0
7
+ * @version 1.3.0
8
+ */
9
+ const TaskMonitor = Function.inherits('Alchemy.Element.App', 'Alchemy.Element.Chimera', 'TaskMonitor');
10
+
11
+ /**
12
+ * Set the custom element prefix to 'chimera'
13
+ *
14
+ * @author Jelle De Loecker <jelle@elevenways.be>
15
+ * @since 1.3.0
16
+ * @version 1.3.0
17
+ */
18
+ TaskMonitor.setStatic('custom_element_prefix', 'chimera');
19
+
20
+ /**
21
+ * The template file
22
+ *
23
+ * @author Jelle De Loecker <jelle@elevenways.be>
24
+ * @since 1.3.0
25
+ * @version 1.3.0
26
+ */
27
+ TaskMonitor.setTemplateFile('elements/chimera_task_monitor');
28
+
29
+ /**
30
+ * The task history ID to monitor
31
+ *
32
+ * @author Jelle De Loecker <jelle@elevenways.be>
33
+ * @since 1.3.0
34
+ * @version 1.3.0
35
+ */
36
+ TaskMonitor.setAttribute('task-history-id');
37
+
38
+ /**
39
+ * The history document (passed from server)
40
+ *
41
+ * @author Jelle De Loecker <jelle@elevenways.be>
42
+ * @since 1.3.0
43
+ * @version 1.3.0
44
+ */
45
+ TaskMonitor.setAssignedProperty('history_doc');
46
+
47
+ /**
48
+ * The system task document (passed from server)
49
+ *
50
+ * @author Jelle De Loecker <jelle@elevenways.be>
51
+ * @since 1.3.0
52
+ * @version 1.3.0
53
+ */
54
+ TaskMonitor.setAssignedProperty('system_task');
55
+
56
+ /**
57
+ * Get or initialize the current task state
58
+ * (stored as _task_state to persist modifications)
59
+ *
60
+ * @author Jelle De Loecker <jelle@elevenways.be>
61
+ * @since 1.3.0
62
+ * @version 1.3.0
63
+ */
64
+ TaskMonitor.setProperty(function task_state() {
65
+
66
+ // Check if we need to reinitialize (history_doc changed)
67
+ let current_pk = this.history_doc?.$pk;
68
+ if (this._task_state && this._task_state_doc_pk !== current_pk) {
69
+ this._task_state = null; // Invalidate cache
70
+ }
71
+
72
+ // Return existing state if already initialized
73
+ if (this._task_state) {
74
+ return this._task_state;
75
+ }
76
+
77
+ // Store which history_doc we're initializing from
78
+ this._task_state_doc_pk = current_pk;
79
+
80
+ // Initialize from history_doc on first access
81
+ this._task_state = {
82
+ is_running : this.history_doc?.is_running ?? false,
83
+ percentage : null,
84
+ is_paused : false,
85
+ can_stop : false,
86
+ can_pause : false,
87
+ had_error : this.history_doc?.had_error ?? false,
88
+ started_at : this.history_doc?.started_at,
89
+ ended_at : this.history_doc?.ended_at,
90
+ };
91
+
92
+ return this._task_state;
93
+ });
94
+
95
+ /**
96
+ * The element has been added to the DOM
97
+ *
98
+ * @author Jelle De Loecker <jelle@elevenways.be>
99
+ * @since 1.3.0
100
+ * @version 1.3.0
101
+ */
102
+ TaskMonitor.setMethod(function introduced() {
103
+
104
+ if (!Blast.isBrowser) {
105
+ return;
106
+ }
107
+
108
+ // Set up event listeners for control buttons
109
+ this.onEventSelector('click', '.btn-stop', e => {
110
+ e.preventDefault();
111
+ this.stopTask();
112
+ });
113
+
114
+ this.onEventSelector('click', '.btn-pause', e => {
115
+ e.preventDefault();
116
+ this.pauseTask();
117
+ });
118
+
119
+ this.onEventSelector('click', '.btn-resume', e => {
120
+ e.preventDefault();
121
+ this.resumeTask();
122
+ });
123
+
124
+ // Initialize display from history_doc (for already-completed tasks)
125
+ this.updateStatus();
126
+ this.updateProgress();
127
+ this.updateControls();
128
+
129
+ // Start the linkup connection
130
+ this.connectLinkup();
131
+
132
+ // Start elapsed time updates
133
+ this.startElapsedTimeUpdates();
134
+ });
135
+
136
+ /**
137
+ * Connect to the server via linkup
138
+ *
139
+ * @author Jelle De Loecker <jelle@elevenways.be>
140
+ * @since 1.3.0
141
+ * @version 1.3.0
142
+ */
143
+ TaskMonitor.setMethod(function connectLinkup() {
144
+
145
+ let task_history_id = this.task_history_id;
146
+
147
+ if (!task_history_id) {
148
+ this.showError(this.__('no-task-history-id'));
149
+ return;
150
+ }
151
+
152
+ // Enable websockets
153
+ alchemy.enableWebsockets();
154
+
155
+ // Create the linkup to the taskmonitor route in the chimera section
156
+ // The event name must include the section prefix 'chimera@' because the route
157
+ // is defined on the chimera section (chimera_section.linkup(...))
158
+ try {
159
+ this.linkup = alchemy.linkup('chimera@taskmonitor', {
160
+ task_history_id: task_history_id
161
+ });
162
+ } catch (err) {
163
+ this.showError(this.__('linkup-connection-failed'));
164
+ return;
165
+ }
166
+
167
+ // Handle initial state
168
+ this.linkup.on('state', (data) => {
169
+ this.handleState(data);
170
+ });
171
+
172
+ // Handle progress updates
173
+ this.linkup.on('progress', (data) => {
174
+ this.handleProgress(data);
175
+ });
176
+
177
+ // Handle new reports (logs)
178
+ this.linkup.on('report', (data) => {
179
+ this.handleReport(data);
180
+ });
181
+
182
+ // Handle completion
183
+ this.linkup.on('complete', (data) => {
184
+ this.handleComplete(data);
185
+ });
186
+
187
+ // Handle errors (both protocol errors with data.message and connection errors with err.message)
188
+ this.linkup.on('error', (err) => {
189
+ let error_message = err?.message || err;
190
+ this.showError(error_message);
191
+
192
+ // Also append to log for connection-level errors
193
+ if (err instanceof Error) {
194
+ this.appendLog(this.__('connection-error') + ': ' + error_message, 'error');
195
+ }
196
+
197
+ // Stop elapsed time updates if the task is not running
198
+ if (!this.task_state.is_running) {
199
+ this.stopElapsedTimeUpdates();
200
+ }
201
+ });
202
+
203
+ // Handle connection close (server disconnect, network issues)
204
+ this.linkup.on('close', () => {
205
+ if (this.task_state.is_running) {
206
+ this.appendLog(this.__('connection-lost'), 'warning');
207
+ }
208
+ this.stopElapsedTimeUpdates();
209
+ });
210
+
211
+ // Handle pause/resume/stop confirmations
212
+ this.linkup.on('paused', () => {
213
+ this.task_state.is_paused = true;
214
+ this.updateControls();
215
+ this.appendLog(this.__('task-paused'), 'info');
216
+ });
217
+
218
+ this.linkup.on('resumed', () => {
219
+ this.task_state.is_paused = false;
220
+ this.updateControls();
221
+ this.appendLog(this.__('task-resumed'), 'info');
222
+ });
223
+
224
+ this.linkup.on('stopped', () => {
225
+ this.task_state.is_running = false;
226
+ this.updateControls();
227
+ this.appendLog(this.__('task-stopped-by-user'), 'warning');
228
+ });
229
+ });
230
+
231
+ /**
232
+ * Handle initial state from server
233
+ *
234
+ * @author Jelle De Loecker <jelle@elevenways.be>
235
+ * @since 1.3.0
236
+ * @version 1.3.0
237
+ */
238
+ TaskMonitor.setMethod(function handleState(data) {
239
+
240
+ this.task_state.is_running = data.is_running;
241
+ this.task_state.percentage = data.percentage;
242
+ this.task_state.is_paused = data.is_paused;
243
+ this.task_state.can_stop = data.can_stop;
244
+ this.task_state.can_pause = data.can_pause;
245
+ this.task_state.had_error = data.had_error;
246
+ this.task_state.started_at = data.started_at;
247
+ this.task_state.ended_at = data.ended_at;
248
+
249
+ // Display existing reports
250
+ if (data.reports && data.reports.length) {
251
+ for (let report of data.reports) {
252
+ this.displayReport(report);
253
+ }
254
+ }
255
+
256
+ this.updateStatus();
257
+ this.updateProgress();
258
+ this.updateControls();
259
+ });
260
+
261
+ /**
262
+ * Handle progress update
263
+ *
264
+ * @author Jelle De Loecker <jelle@elevenways.be>
265
+ * @since 1.3.0
266
+ * @version 1.3.0
267
+ */
268
+ TaskMonitor.setMethod(function handleProgress(data) {
269
+
270
+ this.task_state.percentage = data.percentage;
271
+ this.task_state.is_paused = data.is_paused;
272
+
273
+ this.updateProgress();
274
+ this.updateControls();
275
+ });
276
+
277
+ /**
278
+ * Handle a new report (log entry)
279
+ *
280
+ * @author Jelle De Loecker <jelle@elevenways.be>
281
+ * @since 1.3.0
282
+ * @version 1.3.0
283
+ */
284
+ TaskMonitor.setMethod(function handleReport(report) {
285
+ this.displayReport(report);
286
+ });
287
+
288
+ /**
289
+ * Display a report in the logs
290
+ *
291
+ * @author Jelle De Loecker <jelle@elevenways.be>
292
+ * @since 1.3.0
293
+ * @version 1.3.0
294
+ */
295
+ TaskMonitor.setMethod(function displayReport(report) {
296
+
297
+ if (!report) {
298
+ return;
299
+ }
300
+
301
+ let type_class = 'log-info';
302
+
303
+ if (report.type === 'done') {
304
+ type_class = 'log-success';
305
+ } else if (report.type === 'failed' || report.error) {
306
+ type_class = 'log-error';
307
+ } else if (report.type === 'stopped') {
308
+ type_class = 'log-warning';
309
+ }
310
+
311
+ // Log the report type/status
312
+ if (report.type) {
313
+ let percentage_str = '';
314
+ if (report.percentage != null) {
315
+ percentage_str = ` (${report.percentage}%)`;
316
+ }
317
+ this.appendLog(`[${report.type}]${percentage_str}`, type_class);
318
+ }
319
+
320
+ // Log any messages
321
+ if (report.logs && report.logs.length) {
322
+ for (let log_entry of report.logs) {
323
+ let message = log_entry.args ? log_entry.args.join(' ') : '';
324
+ this.appendLog(message, 'log-detail');
325
+ }
326
+ }
327
+
328
+ // Log any errors
329
+ if (report.error) {
330
+ let error_message = report.error.message || report.error.toString();
331
+ this.appendLog(this.__('error') + ': ' + error_message, 'log-error');
332
+
333
+ if (report.error.stack) {
334
+ this.appendLog(report.error.stack, 'log-error log-stack');
335
+ }
336
+ }
337
+ });
338
+
339
+ /**
340
+ * Handle task completion
341
+ *
342
+ * @author Jelle De Loecker <jelle@elevenways.be>
343
+ * @since 1.3.0
344
+ * @version 1.3.0
345
+ */
346
+ TaskMonitor.setMethod(function handleComplete(data) {
347
+
348
+ this.task_state.is_running = false;
349
+ this.task_state.ended_at = data.ended_at;
350
+ this.task_state.had_error = data.had_error;
351
+
352
+ if (data.had_error && data.error_message) {
353
+ this.appendLog(this.__('task-failed') + ': ' + data.error_message, 'log-error');
354
+ } else if (!data.had_error) {
355
+ this.task_state.percentage = 100;
356
+ this.appendLog(this.__('task-completed-successfully'), 'log-success');
357
+ }
358
+
359
+ this.updateStatus();
360
+ this.updateProgress();
361
+ this.updateControls();
362
+
363
+ // Stop the elapsed time updates
364
+ this.stopElapsedTimeUpdates();
365
+ });
366
+
367
+ /**
368
+ * Update the status display
369
+ *
370
+ * @author Jelle De Loecker <jelle@elevenways.be>
371
+ * @since 1.3.0
372
+ * @version 1.3.0
373
+ */
374
+ TaskMonitor.setMethod(function updateStatus() {
375
+
376
+ let status_el = this.querySelector('.task-status');
377
+
378
+ if (!status_el) {
379
+ return;
380
+ }
381
+
382
+ let status_text = '';
383
+ let status_class = '';
384
+
385
+ if (this.task_state.is_running) {
386
+ if (this.task_state.is_paused) {
387
+ status_text = this.__('task-status-paused');
388
+ status_class = 'status-paused';
389
+ } else {
390
+ status_text = this.__('task-status-running');
391
+ status_class = 'status-running';
392
+ }
393
+ } else if (this.task_state.had_error) {
394
+ status_text = this.__('task-status-failed');
395
+ status_class = 'status-error';
396
+ } else if (this.task_state.ended_at) {
397
+ status_text = this.__('task-status-completed');
398
+ status_class = 'status-completed';
399
+ } else {
400
+ status_text = this.__('task-status-pending');
401
+ status_class = 'status-pending';
402
+ }
403
+
404
+ status_el.textContent = status_text;
405
+ status_el.className = 'task-status ' + status_class;
406
+ });
407
+
408
+ /**
409
+ * Update the progress bar
410
+ *
411
+ * @author Jelle De Loecker <jelle@elevenways.be>
412
+ * @since 1.3.0
413
+ * @version 1.3.0
414
+ */
415
+ TaskMonitor.setMethod(function updateProgress() {
416
+
417
+ let progress_bar = this.querySelector('.progress-bar');
418
+ let progress_text = this.querySelector('.progress-text');
419
+
420
+ if (progress_bar) {
421
+ let percentage = this.task_state.percentage ?? 0;
422
+ progress_bar.style.width = percentage + '%';
423
+
424
+ if (this.task_state.had_error) {
425
+ progress_bar.classList.add('error');
426
+ } else if (percentage >= 100) {
427
+ progress_bar.classList.add('complete');
428
+ } else {
429
+ progress_bar.classList.remove('error', 'complete');
430
+ }
431
+ }
432
+
433
+ if (progress_text) {
434
+ let percentage = this.task_state.percentage;
435
+ if (percentage != null) {
436
+ progress_text.textContent = Math.round(percentage) + '%';
437
+ } else {
438
+ progress_text.textContent = '-';
439
+ }
440
+ }
441
+ });
442
+
443
+ /**
444
+ * Update the control buttons visibility
445
+ *
446
+ * @author Jelle De Loecker <jelle@elevenways.be>
447
+ * @since 1.3.0
448
+ * @version 1.3.0
449
+ */
450
+ TaskMonitor.setMethod(function updateControls() {
451
+
452
+ let stop_btn = this.querySelector('.btn-stop');
453
+ let pause_btn = this.querySelector('.btn-pause');
454
+ let resume_btn = this.querySelector('.btn-resume');
455
+
456
+ if (stop_btn) {
457
+ stop_btn.disabled = !this.task_state.is_running || !this.task_state.can_stop;
458
+ stop_btn.style.display = this.task_state.is_running && this.task_state.can_stop ? '' : 'none';
459
+ }
460
+
461
+ if (pause_btn) {
462
+ pause_btn.disabled = !this.task_state.is_running || this.task_state.is_paused || !this.task_state.can_pause;
463
+ pause_btn.style.display = this.task_state.is_running && !this.task_state.is_paused && this.task_state.can_pause ? '' : 'none';
464
+ }
465
+
466
+ if (resume_btn) {
467
+ resume_btn.disabled = !this.task_state.is_paused;
468
+ resume_btn.style.display = this.task_state.is_paused ? '' : 'none';
469
+ }
470
+ });
471
+
472
+ /**
473
+ * Append a log message to the output
474
+ *
475
+ * @author Jelle De Loecker <jelle@elevenways.be>
476
+ * @since 1.3.0
477
+ * @version 1.3.0
478
+ */
479
+ TaskMonitor.setMethod(function appendLog(message, type_class) {
480
+
481
+ let log_output = this.querySelector('.log-output');
482
+
483
+ if (!log_output) {
484
+ return;
485
+ }
486
+
487
+ let log_entry = document.createElement('div');
488
+ log_entry.className = 'log-entry ' + (type_class || '');
489
+
490
+ let timestamp = document.createElement('span');
491
+ timestamp.className = 'log-timestamp';
492
+ timestamp.textContent = new Date().format('H:i:s');
493
+
494
+ let content = document.createElement('span');
495
+ content.className = 'log-content';
496
+ content.textContent = message;
497
+
498
+ log_entry.appendChild(timestamp);
499
+ log_entry.appendChild(content);
500
+ log_output.appendChild(log_entry);
501
+
502
+ // Auto-scroll to bottom
503
+ log_output.scrollTop = log_output.scrollHeight;
504
+ });
505
+
506
+ /**
507
+ * Show an error message
508
+ *
509
+ * @author Jelle De Loecker <jelle@elevenways.be>
510
+ * @since 1.3.0
511
+ * @version 1.3.0
512
+ */
513
+ TaskMonitor.setMethod(function showError(message) {
514
+
515
+ let error_el = this.querySelector('.task-error');
516
+
517
+ if (error_el) {
518
+ error_el.textContent = message;
519
+ error_el.style.display = 'block';
520
+ }
521
+
522
+ this.appendLog(this.__('error') + ': ' + message, 'log-error');
523
+ });
524
+
525
+ /**
526
+ * Send stop command to server
527
+ *
528
+ * @author Jelle De Loecker <jelle@elevenways.be>
529
+ * @since 1.3.0
530
+ * @version 1.3.0
531
+ */
532
+ TaskMonitor.setMethod(function stopTask() {
533
+ if (this.linkup) {
534
+ this.linkup.submit('stop');
535
+ this.appendLog(this.__('sending-stop-command'), 'log-info');
536
+ }
537
+ });
538
+
539
+ /**
540
+ * Send pause command to server
541
+ *
542
+ * @author Jelle De Loecker <jelle@elevenways.be>
543
+ * @since 1.3.0
544
+ * @version 1.3.0
545
+ */
546
+ TaskMonitor.setMethod(function pauseTask() {
547
+ if (this.linkup) {
548
+ this.linkup.submit('pause');
549
+ this.appendLog(this.__('sending-pause-command'), 'log-info');
550
+ }
551
+ });
552
+
553
+ /**
554
+ * Send resume command to server
555
+ *
556
+ * @author Jelle De Loecker <jelle@elevenways.be>
557
+ * @since 1.3.0
558
+ * @version 1.3.0
559
+ */
560
+ TaskMonitor.setMethod(function resumeTask() {
561
+ if (this.linkup) {
562
+ this.linkup.submit('resume');
563
+ this.appendLog(this.__('sending-resume-command'), 'log-info');
564
+ }
565
+ });
566
+
567
+ /**
568
+ * Start updating elapsed time
569
+ *
570
+ * @author Jelle De Loecker <jelle@elevenways.be>
571
+ * @since 1.3.0
572
+ * @version 1.3.0
573
+ */
574
+ TaskMonitor.setMethod(function startElapsedTimeUpdates() {
575
+
576
+ // Clear any existing interval first to prevent race conditions
577
+ this.stopElapsedTimeUpdates();
578
+
579
+ this.updateElapsedTime();
580
+
581
+ this._elapsed_interval = setInterval(() => {
582
+ this.updateElapsedTime();
583
+ }, 1000);
584
+ });
585
+
586
+ /**
587
+ * Stop updating elapsed time
588
+ *
589
+ * @author Jelle De Loecker <jelle@elevenways.be>
590
+ * @since 1.3.0
591
+ * @version 1.3.0
592
+ */
593
+ TaskMonitor.setMethod(function stopElapsedTimeUpdates() {
594
+ if (this._elapsed_interval) {
595
+ clearInterval(this._elapsed_interval);
596
+ this._elapsed_interval = null;
597
+ }
598
+
599
+ // Do one final update
600
+ this.updateElapsedTime();
601
+ });
602
+
603
+ /**
604
+ * Update the elapsed time display
605
+ *
606
+ * @author Jelle De Loecker <jelle@elevenways.be>
607
+ * @since 1.3.0
608
+ * @version 1.3.0
609
+ */
610
+ TaskMonitor.setMethod(function updateElapsedTime() {
611
+
612
+ let elapsed_el = this.querySelector('.elapsed-time');
613
+
614
+ if (!elapsed_el) {
615
+ return;
616
+ }
617
+
618
+ let started_at = this.task_state.started_at || this.history_doc?.started_at;
619
+ let ended_at = this.task_state.ended_at || this.history_doc?.ended_at;
620
+
621
+ if (!started_at) {
622
+ elapsed_el.textContent = '-';
623
+ return;
624
+ }
625
+
626
+ let start_time = new Date(started_at).getTime();
627
+ let end_time = ended_at ? new Date(ended_at).getTime() : Date.now();
628
+ let elapsed_ms = end_time - start_time;
629
+
630
+ if (elapsed_ms < 0) {
631
+ elapsed_el.textContent = '-';
632
+ return;
633
+ }
634
+
635
+ // Format as HH:MM:SS
636
+ let seconds = Math.floor(elapsed_ms / 1000);
637
+ let minutes = Math.floor(seconds / 60);
638
+ let hours = Math.floor(minutes / 60);
639
+
640
+ seconds = seconds % 60;
641
+ minutes = minutes % 60;
642
+
643
+ let parts = [];
644
+
645
+ if (hours > 0) {
646
+ parts.push(String(hours).padStart(2, '0'));
647
+ }
648
+
649
+ parts.push(String(minutes).padStart(2, '0'));
650
+ parts.push(String(seconds).padStart(2, '0'));
651
+
652
+ elapsed_el.textContent = parts.join(':');
653
+ });
654
+
655
+ /**
656
+ * Clean up when element is removed
657
+ *
658
+ * @author Jelle De Loecker <jelle@elevenways.be>
659
+ * @since 1.3.0
660
+ * @version 1.3.0
661
+ */
662
+ TaskMonitor.setMethod(function removed() {
663
+
664
+ // Destroy the linkup
665
+ if (this.linkup) {
666
+ this.linkup.destroy();
667
+ this.linkup = null;
668
+ }
669
+
670
+ // Stop elapsed time updates
671
+ this.stopElapsedTimeUpdates();
672
+ });