job_harbor 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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +98 -0
  3. data/Rakefile +6 -0
  4. data/app/assets/stylesheets/solidqueue_dashboard/application.css +1 -0
  5. data/app/components/job_harbor/application_component.rb +13 -0
  6. data/app/components/job_harbor/badge_component.rb +26 -0
  7. data/app/components/job_harbor/chart_component.rb +82 -0
  8. data/app/components/job_harbor/empty_state_component.rb +41 -0
  9. data/app/components/job_harbor/failure_rates_component.rb +84 -0
  10. data/app/components/job_harbor/job_filters_component.rb +92 -0
  11. data/app/components/job_harbor/job_row_component.rb +106 -0
  12. data/app/components/job_harbor/nav_link_component.rb +50 -0
  13. data/app/components/job_harbor/pagination_component.rb +72 -0
  14. data/app/components/job_harbor/per_page_selector_component.rb +40 -0
  15. data/app/components/job_harbor/queue_card_component.rb +59 -0
  16. data/app/components/job_harbor/refresh_selector_component.rb +57 -0
  17. data/app/components/job_harbor/stat_card_component.rb +77 -0
  18. data/app/components/job_harbor/theme_toggle_component.rb +48 -0
  19. data/app/components/job_harbor/worker_card_component.rb +86 -0
  20. data/app/controllers/job_harbor/application_controller.rb +44 -0
  21. data/app/controllers/job_harbor/dashboard_controller.rb +17 -0
  22. data/app/controllers/job_harbor/jobs_controller.rb +151 -0
  23. data/app/controllers/job_harbor/queues_controller.rb +40 -0
  24. data/app/controllers/job_harbor/recurring_tasks_controller.rb +35 -0
  25. data/app/controllers/job_harbor/workers_controller.rb +12 -0
  26. data/app/helpers/job_harbor/application_helper.rb +4 -0
  27. data/app/models/job_harbor/chart_data.rb +104 -0
  28. data/app/models/job_harbor/dashboard_stats.rb +90 -0
  29. data/app/models/job_harbor/failure_stats.rb +63 -0
  30. data/app/models/job_harbor/job_presenter.rb +246 -0
  31. data/app/models/job_harbor/queue_stats.rb +77 -0
  32. data/app/views/job_harbor/dashboard/index.html.erb +112 -0
  33. data/app/views/job_harbor/jobs/index.html.erb +100 -0
  34. data/app/views/job_harbor/jobs/search.html.erb +43 -0
  35. data/app/views/job_harbor/jobs/show.html.erb +133 -0
  36. data/app/views/job_harbor/queues/index.html.erb +13 -0
  37. data/app/views/job_harbor/queues/show.html.erb +88 -0
  38. data/app/views/job_harbor/recurring_tasks/index.html.erb +36 -0
  39. data/app/views/job_harbor/recurring_tasks/show.html.erb +97 -0
  40. data/app/views/job_harbor/workers/index.html.erb +33 -0
  41. data/app/views/layouts/job_harbor/application.html.erb +1434 -0
  42. data/config/routes.rb +39 -0
  43. data/lib/job_harbor/configuration.rb +31 -0
  44. data/lib/job_harbor/engine.rb +28 -0
  45. data/lib/job_harbor/version.rb +3 -0
  46. data/lib/job_harbor.rb +19 -0
  47. data/lib/tasks/solidqueue_dashboard_tasks.rake +4 -0
  48. metadata +134 -0
@@ -0,0 +1,1434 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <title><%= @page_title ? "#{@page_title} - Job Harbor" : "Job Harbor" %></title>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta charset="utf-8">
7
+ <%= csrf_meta_tags %>
8
+ <%= csp_meta_tag %>
9
+
10
+ <style>
11
+ /* Job Harbor - Self-contained CSS */
12
+ /* Uses CSS variables for theming */
13
+
14
+ :root {
15
+ /* Colors - Amber/Dark theme */
16
+ --sqd-primary: #f59e0b;
17
+ --sqd-primary-hover: #d97706;
18
+ --sqd-primary-light: rgba(245, 158, 11, 0.1);
19
+
20
+ --sqd-success: #10b981;
21
+ --sqd-warning: #f59e0b;
22
+ --sqd-danger: #ef4444;
23
+ --sqd-info: #3b82f6;
24
+
25
+ /* Dark theme (default) */
26
+ --sqd-bg-primary: #111827;
27
+ --sqd-bg-secondary: #1f2937;
28
+ --sqd-bg-tertiary: #374151;
29
+ --sqd-border: #374151;
30
+ --sqd-text-primary: #f9fafb;
31
+ --sqd-text-secondary: #9ca3af;
32
+ --sqd-text-muted: #6b7280;
33
+
34
+ /* Spacing */
35
+ --sqd-space-1: 0.25rem;
36
+ --sqd-space-2: 0.5rem;
37
+ --sqd-space-3: 0.75rem;
38
+ --sqd-space-4: 1rem;
39
+ --sqd-space-6: 1.5rem;
40
+ --sqd-space-8: 2rem;
41
+
42
+ /* Sizing */
43
+ --sqd-sidebar-width: 240px;
44
+ --sqd-radius: 0.5rem;
45
+ --sqd-radius-sm: 0.25rem;
46
+ }
47
+
48
+ .sqd-theme-light {
49
+ --sqd-bg-primary: #ffffff;
50
+ --sqd-bg-secondary: #f9fafb;
51
+ --sqd-bg-tertiary: #f3f4f6;
52
+ --sqd-border: #e5e7eb;
53
+ --sqd-text-primary: #111827;
54
+ --sqd-text-secondary: #4b5563;
55
+ --sqd-text-muted: #9ca3af;
56
+ }
57
+
58
+ /* Reset & Base */
59
+ *, *::before, *::after {
60
+ box-sizing: border-box;
61
+ }
62
+
63
+ .sqd-body {
64
+ margin: 0;
65
+ padding: 0;
66
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
67
+ font-size: 14px;
68
+ line-height: 1.5;
69
+ background-color: var(--sqd-bg-primary);
70
+ color: var(--sqd-text-primary);
71
+ min-height: 100vh;
72
+ }
73
+
74
+ /* Layout */
75
+ .sqd-layout {
76
+ display: flex;
77
+ min-height: 100vh;
78
+ }
79
+
80
+ /* Sidebar */
81
+ .sqd-sidebar {
82
+ width: var(--sqd-sidebar-width);
83
+ background-color: var(--sqd-bg-secondary);
84
+ border-right: 1px solid var(--sqd-border);
85
+ display: flex;
86
+ flex-direction: column;
87
+ position: fixed;
88
+ top: 0;
89
+ left: 0;
90
+ bottom: 0;
91
+ z-index: 50;
92
+ }
93
+
94
+ .sqd-sidebar-header {
95
+ padding: var(--sqd-space-4);
96
+ border-bottom: 1px solid var(--sqd-border);
97
+ }
98
+
99
+ .sqd-logo-link {
100
+ display: flex;
101
+ align-items: center;
102
+ gap: var(--sqd-space-2);
103
+ text-decoration: none;
104
+ color: var(--sqd-text-primary);
105
+ }
106
+
107
+ .sqd-logo {
108
+ width: 24px;
109
+ height: 24px;
110
+ color: var(--sqd-primary);
111
+ }
112
+
113
+ .sqd-logo-text {
114
+ font-weight: 600;
115
+ font-size: 16px;
116
+ }
117
+
118
+ .sqd-nav {
119
+ flex: 1;
120
+ padding: var(--sqd-space-4) 0;
121
+ overflow-y: auto;
122
+ }
123
+
124
+ .sqd-nav-list {
125
+ list-style: none;
126
+ margin: 0;
127
+ padding: 0;
128
+ }
129
+
130
+ .sqd-nav-link {
131
+ display: flex;
132
+ align-items: center;
133
+ gap: var(--sqd-space-3);
134
+ padding: var(--sqd-space-3) var(--sqd-space-4);
135
+ color: var(--sqd-text-secondary);
136
+ text-decoration: none;
137
+ transition: all 0.15s ease;
138
+ }
139
+
140
+ .sqd-nav-link:hover {
141
+ background-color: var(--sqd-bg-tertiary);
142
+ color: var(--sqd-text-primary);
143
+ }
144
+
145
+ .sqd-nav-link.active {
146
+ background-color: var(--sqd-primary-light);
147
+ color: var(--sqd-primary);
148
+ border-right: 2px solid var(--sqd-primary);
149
+ }
150
+
151
+ .sqd-nav-icon {
152
+ width: 20px;
153
+ height: 20px;
154
+ flex-shrink: 0;
155
+ }
156
+
157
+ .sqd-nav-badge {
158
+ margin-left: auto;
159
+ padding: 2px 6px;
160
+ font-size: 11px;
161
+ font-weight: 600;
162
+ background-color: var(--sqd-bg-tertiary);
163
+ border-radius: var(--sqd-radius-sm);
164
+ color: var(--sqd-text-secondary);
165
+ }
166
+
167
+ .sqd-nav-link.active .sqd-nav-badge {
168
+ background-color: var(--sqd-primary);
169
+ color: #000;
170
+ }
171
+
172
+ .sqd-sidebar-footer {
173
+ padding: var(--sqd-space-4);
174
+ border-top: 1px solid var(--sqd-border);
175
+ }
176
+
177
+ .sqd-sidebar-footer-row {
178
+ display: flex;
179
+ align-items: center;
180
+ justify-content: space-between;
181
+ }
182
+
183
+ .sqd-version {
184
+ font-size: 12px;
185
+ color: var(--sqd-text-muted);
186
+ }
187
+
188
+ /* Theme Toggle */
189
+ .sqd-theme-toggle {
190
+ display: flex;
191
+ align-items: center;
192
+ justify-content: center;
193
+ width: 32px;
194
+ height: 32px;
195
+ padding: 0;
196
+ border: 1px solid var(--sqd-border);
197
+ border-radius: var(--sqd-radius);
198
+ background-color: var(--sqd-bg-tertiary);
199
+ color: var(--sqd-text-secondary);
200
+ cursor: pointer;
201
+ transition: all 0.15s ease;
202
+ }
203
+
204
+ .sqd-theme-toggle:hover {
205
+ background-color: var(--sqd-border);
206
+ color: var(--sqd-text-primary);
207
+ }
208
+
209
+ .sqd-theme-icon {
210
+ width: 18px;
211
+ height: 18px;
212
+ }
213
+
214
+ /* Dark theme: show sun icon, hide moon */
215
+ .sqd-body:not(.sqd-theme-light) .sqd-theme-icon-sun {
216
+ display: block;
217
+ }
218
+
219
+ .sqd-body:not(.sqd-theme-light) .sqd-theme-icon-moon {
220
+ display: none;
221
+ }
222
+
223
+ /* Light theme: show moon icon, hide sun */
224
+ .sqd-body.sqd-theme-light .sqd-theme-icon-sun {
225
+ display: none;
226
+ }
227
+
228
+ .sqd-body.sqd-theme-light .sqd-theme-icon-moon {
229
+ display: block;
230
+ }
231
+
232
+ /* Refresh Selector */
233
+ .sqd-refresh-selector {
234
+ display: flex;
235
+ align-items: center;
236
+ gap: var(--sqd-space-2);
237
+ }
238
+
239
+ .sqd-refresh-icon {
240
+ width: 16px;
241
+ height: 16px;
242
+ color: var(--sqd-text-muted);
243
+ }
244
+
245
+ .sqd-refresh-select {
246
+ padding: var(--sqd-space-1) var(--sqd-space-2);
247
+ font-size: 13px;
248
+ background-color: var(--sqd-bg-tertiary);
249
+ border: 1px solid var(--sqd-border);
250
+ border-radius: var(--sqd-radius-sm);
251
+ color: var(--sqd-text-primary);
252
+ cursor: pointer;
253
+ }
254
+
255
+ .sqd-refresh-select:focus {
256
+ outline: none;
257
+ border-color: var(--sqd-primary);
258
+ }
259
+
260
+ .sqd-refresh-select option {
261
+ background-color: var(--sqd-bg-secondary);
262
+ color: var(--sqd-text-primary);
263
+ }
264
+
265
+ /* Per-Page Selector */
266
+ .sqd-per-page-selector {
267
+ display: flex;
268
+ align-items: center;
269
+ gap: var(--sqd-space-2);
270
+ }
271
+
272
+ .sqd-per-page-label {
273
+ font-size: 13px;
274
+ color: var(--sqd-text-muted);
275
+ }
276
+
277
+ .sqd-per-page-select {
278
+ padding: var(--sqd-space-1) var(--sqd-space-2);
279
+ font-size: 13px;
280
+ background-color: var(--sqd-bg-tertiary);
281
+ border: 1px solid var(--sqd-border);
282
+ border-radius: var(--sqd-radius-sm);
283
+ color: var(--sqd-text-primary);
284
+ cursor: pointer;
285
+ }
286
+
287
+ .sqd-per-page-select:focus {
288
+ outline: none;
289
+ border-color: var(--sqd-primary);
290
+ }
291
+
292
+ .sqd-per-page-select option {
293
+ background-color: var(--sqd-bg-secondary);
294
+ color: var(--sqd-text-primary);
295
+ }
296
+
297
+ /* Pagination Row */
298
+ .sqd-pagination-row {
299
+ display: flex;
300
+ align-items: center;
301
+ justify-content: space-between;
302
+ margin-top: var(--sqd-space-4);
303
+ gap: var(--sqd-space-4);
304
+ }
305
+
306
+ @media (max-width: 768px) {
307
+ .sqd-pagination-row {
308
+ flex-direction: column;
309
+ align-items: center;
310
+ }
311
+ }
312
+
313
+ /* Retry Badge */
314
+ .sqd-retry-badge {
315
+ margin-left: var(--sqd-space-2);
316
+ padding: 1px 5px;
317
+ font-size: 11px;
318
+ font-weight: 600;
319
+ background-color: rgba(245, 158, 11, 0.15);
320
+ color: var(--sqd-warning);
321
+ border-radius: var(--sqd-radius-sm);
322
+ }
323
+
324
+ /* Running Duration */
325
+ .sqd-running-duration {
326
+ font-size: 12px;
327
+ color: var(--sqd-info);
328
+ }
329
+
330
+ /* Relative Time */
331
+ .sqd-relative-time {
332
+ font-size: 12px;
333
+ }
334
+
335
+ /* Job Filters */
336
+ .sqd-filters {
337
+ display: flex;
338
+ gap: var(--sqd-space-4);
339
+ margin-bottom: var(--sqd-space-4);
340
+ flex-wrap: wrap;
341
+ }
342
+
343
+ .sqd-filter-group {
344
+ display: flex;
345
+ align-items: center;
346
+ gap: var(--sqd-space-2);
347
+ }
348
+
349
+ .sqd-filter-label {
350
+ font-size: 13px;
351
+ color: var(--sqd-text-muted);
352
+ }
353
+
354
+ .sqd-filter-select {
355
+ padding: var(--sqd-space-1) var(--sqd-space-3);
356
+ font-size: 13px;
357
+ background-color: var(--sqd-bg-tertiary);
358
+ border: 1px solid var(--sqd-border);
359
+ border-radius: var(--sqd-radius-sm);
360
+ color: var(--sqd-text-primary);
361
+ cursor: pointer;
362
+ min-width: 150px;
363
+ }
364
+
365
+ .sqd-filter-select:focus {
366
+ outline: none;
367
+ border-color: var(--sqd-primary);
368
+ }
369
+
370
+ .sqd-filter-select option {
371
+ background-color: var(--sqd-bg-secondary);
372
+ color: var(--sqd-text-primary);
373
+ }
374
+
375
+ @media (max-width: 768px) {
376
+ .sqd-filters {
377
+ flex-direction: column;
378
+ align-items: stretch;
379
+ }
380
+ }
381
+
382
+ /* Failure Rates */
383
+ .sqd-failure-rates {
384
+ margin-bottom: var(--sqd-space-4);
385
+ }
386
+
387
+ .sqd-failure-table {
388
+ font-size: 13px;
389
+ }
390
+
391
+ .sqd-rate-badge {
392
+ display: inline-block;
393
+ padding: 2px 8px;
394
+ font-size: 12px;
395
+ font-weight: 600;
396
+ border-radius: var(--sqd-radius-sm);
397
+ }
398
+
399
+ .sqd-rate-low {
400
+ background-color: rgba(16, 185, 129, 0.15);
401
+ color: var(--sqd-success);
402
+ }
403
+
404
+ .sqd-rate-medium {
405
+ background-color: rgba(245, 158, 11, 0.15);
406
+ color: var(--sqd-warning);
407
+ }
408
+
409
+ .sqd-rate-high {
410
+ background-color: rgba(239, 68, 68, 0.15);
411
+ color: var(--sqd-danger);
412
+ }
413
+
414
+ /* Chart */
415
+ .sqd-chart-card {
416
+ margin-bottom: var(--sqd-space-4);
417
+ }
418
+
419
+ .sqd-chart-header {
420
+ display: flex;
421
+ align-items: center;
422
+ justify-content: space-between;
423
+ flex-wrap: wrap;
424
+ gap: var(--sqd-space-2);
425
+ }
426
+
427
+ .sqd-chart-ranges {
428
+ display: flex;
429
+ gap: var(--sqd-space-1);
430
+ }
431
+
432
+ .sqd-chart-range-btn {
433
+ padding: var(--sqd-space-1) var(--sqd-space-2);
434
+ font-size: 12px;
435
+ border-radius: var(--sqd-radius-sm);
436
+ background-color: var(--sqd-bg-tertiary);
437
+ color: var(--sqd-text-secondary);
438
+ text-decoration: none;
439
+ transition: all 0.15s ease;
440
+ }
441
+
442
+ .sqd-chart-range-btn:hover {
443
+ background-color: var(--sqd-border);
444
+ color: var(--sqd-text-primary);
445
+ }
446
+
447
+ .sqd-chart-range-btn.active {
448
+ background-color: var(--sqd-primary);
449
+ color: #000;
450
+ }
451
+
452
+ .sqd-chart-legend {
453
+ display: flex;
454
+ gap: var(--sqd-space-4);
455
+ margin-bottom: var(--sqd-space-3);
456
+ }
457
+
458
+ .sqd-legend-item {
459
+ display: flex;
460
+ align-items: center;
461
+ gap: var(--sqd-space-2);
462
+ font-size: 12px;
463
+ }
464
+
465
+ .sqd-legend-color {
466
+ width: 12px;
467
+ height: 12px;
468
+ border-radius: 2px;
469
+ }
470
+
471
+ .sqd-chart-completed {
472
+ background-color: var(--sqd-success);
473
+ }
474
+
475
+ .sqd-chart-failed {
476
+ background-color: var(--sqd-danger);
477
+ }
478
+
479
+ .sqd-chart-enqueued {
480
+ background-color: var(--sqd-info);
481
+ }
482
+
483
+ .sqd-chart {
484
+ position: relative;
485
+ width: 100%;
486
+ height: 200px;
487
+ }
488
+
489
+ .sqd-chart canvas {
490
+ width: 100% !important;
491
+ height: 100% !important;
492
+ }
493
+
494
+ /* Main Content */
495
+ .sqd-main {
496
+ flex: 1;
497
+ margin-left: var(--sqd-sidebar-width);
498
+ min-height: 100vh;
499
+ display: flex;
500
+ flex-direction: column;
501
+ }
502
+
503
+ .sqd-header {
504
+ display: flex;
505
+ align-items: center;
506
+ justify-content: space-between;
507
+ padding: var(--sqd-space-4) var(--sqd-space-6);
508
+ background-color: var(--sqd-bg-secondary);
509
+ border-bottom: 1px solid var(--sqd-border);
510
+ }
511
+
512
+ .sqd-page-title {
513
+ margin: 0;
514
+ font-size: 24px;
515
+ font-weight: 600;
516
+ }
517
+
518
+ .sqd-header-actions {
519
+ display: flex;
520
+ gap: var(--sqd-space-2);
521
+ }
522
+
523
+ .sqd-content {
524
+ flex: 1;
525
+ padding: var(--sqd-space-6);
526
+ }
527
+
528
+ /* Flash Messages */
529
+ .sqd-flash {
530
+ padding: 0 var(--sqd-space-6);
531
+ margin-top: var(--sqd-space-4);
532
+ }
533
+
534
+ .sqd-flash-notice,
535
+ .sqd-flash-alert {
536
+ padding: var(--sqd-space-3) var(--sqd-space-4);
537
+ border-radius: var(--sqd-radius);
538
+ margin-bottom: var(--sqd-space-2);
539
+ }
540
+
541
+ .sqd-flash-notice {
542
+ background-color: rgba(16, 185, 129, 0.1);
543
+ border: 1px solid var(--sqd-success);
544
+ color: var(--sqd-success);
545
+ }
546
+
547
+ .sqd-flash-alert {
548
+ background-color: rgba(239, 68, 68, 0.1);
549
+ border: 1px solid var(--sqd-danger);
550
+ color: var(--sqd-danger);
551
+ }
552
+
553
+ /* Cards */
554
+ .sqd-card {
555
+ background-color: var(--sqd-bg-secondary);
556
+ border: 1px solid var(--sqd-border);
557
+ border-radius: var(--sqd-radius);
558
+ overflow: hidden;
559
+ }
560
+
561
+ .sqd-card-header {
562
+ padding: var(--sqd-space-4);
563
+ border-bottom: 1px solid var(--sqd-border);
564
+ }
565
+
566
+ .sqd-card-title {
567
+ margin: 0;
568
+ font-size: 16px;
569
+ font-weight: 600;
570
+ }
571
+
572
+ .sqd-card-body {
573
+ padding: var(--sqd-space-4);
574
+ }
575
+
576
+ /* Stat Cards */
577
+ .sqd-stats-grid {
578
+ display: grid;
579
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
580
+ gap: var(--sqd-space-4);
581
+ }
582
+
583
+ .sqd-stat-card {
584
+ background-color: var(--sqd-bg-secondary);
585
+ border: 1px solid var(--sqd-border);
586
+ border-radius: var(--sqd-radius);
587
+ padding: var(--sqd-space-4);
588
+ display: flex;
589
+ flex-direction: column;
590
+ gap: var(--sqd-space-2);
591
+ }
592
+
593
+ .sqd-stat-header {
594
+ display: flex;
595
+ align-items: center;
596
+ justify-content: space-between;
597
+ }
598
+
599
+ .sqd-stat-label {
600
+ font-size: 14px;
601
+ color: var(--sqd-text-secondary);
602
+ text-transform: uppercase;
603
+ letter-spacing: 0.05em;
604
+ }
605
+
606
+ .sqd-stat-icon {
607
+ width: 20px;
608
+ height: 20px;
609
+ color: var(--sqd-text-muted);
610
+ }
611
+
612
+ .sqd-stat-value {
613
+ font-size: 32px;
614
+ font-weight: 700;
615
+ line-height: 1;
616
+ text-decoration: none;
617
+ }
618
+
619
+ a.sqd-stat-value:hover {
620
+ text-decoration: none;
621
+ opacity: 0.8;
622
+ }
623
+
624
+ .sqd-stat-value.success { color: var(--sqd-success); }
625
+ .sqd-stat-value.warning { color: var(--sqd-warning); }
626
+ .sqd-stat-value.danger { color: var(--sqd-danger); }
627
+ .sqd-stat-value.info { color: var(--sqd-info); }
628
+
629
+ /* Badges */
630
+ .sqd-badge {
631
+ display: inline-flex;
632
+ align-items: center;
633
+ padding: var(--sqd-space-1) var(--sqd-space-2);
634
+ font-size: 12px;
635
+ font-weight: 500;
636
+ border-radius: var(--sqd-radius-sm);
637
+ text-transform: uppercase;
638
+ letter-spacing: 0.05em;
639
+ }
640
+
641
+ .sqd-badge-pending {
642
+ background-color: rgba(59, 130, 246, 0.1);
643
+ color: var(--sqd-info);
644
+ }
645
+
646
+ .sqd-badge-scheduled {
647
+ background-color: rgba(245, 158, 11, 0.1);
648
+ color: var(--sqd-warning);
649
+ }
650
+
651
+ .sqd-badge-in_progress {
652
+ background-color: rgba(59, 130, 246, 0.2);
653
+ color: var(--sqd-info);
654
+ }
655
+
656
+ .sqd-badge-failed {
657
+ background-color: rgba(239, 68, 68, 0.1);
658
+ color: var(--sqd-danger);
659
+ }
660
+
661
+ .sqd-badge-finished {
662
+ background-color: rgba(16, 185, 129, 0.1);
663
+ color: var(--sqd-success);
664
+ }
665
+
666
+ .sqd-badge-blocked {
667
+ background-color: rgba(156, 163, 175, 0.1);
668
+ color: var(--sqd-text-secondary);
669
+ }
670
+
671
+ .sqd-badge-active {
672
+ background-color: rgba(16, 185, 129, 0.1);
673
+ color: var(--sqd-success);
674
+ }
675
+
676
+ .sqd-badge-paused {
677
+ background-color: rgba(245, 158, 11, 0.1);
678
+ color: var(--sqd-warning);
679
+ }
680
+
681
+ /* Tables */
682
+ .sqd-table-container {
683
+ overflow-x: auto;
684
+ }
685
+
686
+ .sqd-table {
687
+ width: 100%;
688
+ border-collapse: collapse;
689
+ }
690
+
691
+ .sqd-table th,
692
+ .sqd-table td {
693
+ padding: var(--sqd-space-3) var(--sqd-space-4);
694
+ text-align: left;
695
+ border-bottom: 1px solid var(--sqd-border);
696
+ }
697
+
698
+ .sqd-table th {
699
+ font-weight: 600;
700
+ color: var(--sqd-text-secondary);
701
+ font-size: 12px;
702
+ text-transform: uppercase;
703
+ letter-spacing: 0.05em;
704
+ background-color: var(--sqd-bg-tertiary);
705
+ }
706
+
707
+ .sqd-table tr:hover {
708
+ background-color: var(--sqd-bg-tertiary);
709
+ }
710
+
711
+ .sqd-table-link {
712
+ color: var(--sqd-primary);
713
+ text-decoration: none;
714
+ }
715
+
716
+ .sqd-table-link:hover {
717
+ text-decoration: underline;
718
+ }
719
+
720
+ /* Buttons */
721
+ .sqd-btn {
722
+ display: inline-flex;
723
+ align-items: center;
724
+ justify-content: center;
725
+ gap: var(--sqd-space-2);
726
+ padding: var(--sqd-space-2) var(--sqd-space-4);
727
+ font-size: 14px;
728
+ font-weight: 500;
729
+ border-radius: var(--sqd-radius);
730
+ border: none;
731
+ cursor: pointer;
732
+ text-decoration: none;
733
+ transition: all 0.15s ease;
734
+ }
735
+
736
+ .sqd-btn-primary {
737
+ background-color: var(--sqd-primary);
738
+ color: #000;
739
+ }
740
+
741
+ .sqd-btn-primary:hover {
742
+ background-color: var(--sqd-primary-hover);
743
+ }
744
+
745
+ .sqd-btn-secondary {
746
+ background-color: var(--sqd-bg-tertiary);
747
+ color: var(--sqd-text-primary);
748
+ border: 1px solid var(--sqd-border);
749
+ }
750
+
751
+ .sqd-btn-secondary:hover {
752
+ background-color: var(--sqd-border);
753
+ }
754
+
755
+ .sqd-btn-danger {
756
+ background-color: var(--sqd-danger);
757
+ color: #fff;
758
+ }
759
+
760
+ .sqd-btn-danger:hover {
761
+ background-color: #dc2626;
762
+ }
763
+
764
+ .sqd-btn-sm {
765
+ padding: var(--sqd-space-1) var(--sqd-space-2);
766
+ font-size: 12px;
767
+ }
768
+
769
+ .sqd-btn-icon {
770
+ padding: var(--sqd-space-2);
771
+ }
772
+
773
+ /* Forms */
774
+ .sqd-form-group {
775
+ margin-bottom: var(--sqd-space-4);
776
+ }
777
+
778
+ .sqd-label {
779
+ display: block;
780
+ margin-bottom: var(--sqd-space-1);
781
+ font-weight: 500;
782
+ color: var(--sqd-text-secondary);
783
+ }
784
+
785
+ .sqd-input,
786
+ .sqd-select {
787
+ width: 100%;
788
+ padding: var(--sqd-space-2) var(--sqd-space-3);
789
+ font-size: 14px;
790
+ background-color: var(--sqd-bg-tertiary);
791
+ border: 1px solid var(--sqd-border);
792
+ border-radius: var(--sqd-radius);
793
+ color: var(--sqd-text-primary);
794
+ }
795
+
796
+ .sqd-input:focus,
797
+ .sqd-select:focus {
798
+ outline: none;
799
+ border-color: var(--sqd-primary);
800
+ box-shadow: 0 0 0 2px var(--sqd-primary-light);
801
+ }
802
+
803
+ .sqd-search-form {
804
+ display: flex;
805
+ gap: var(--sqd-space-2);
806
+ }
807
+
808
+ .sqd-search-form .sqd-input {
809
+ flex: 1;
810
+ }
811
+
812
+ /* Pagination */
813
+ .sqd-pagination {
814
+ display: flex;
815
+ align-items: center;
816
+ justify-content: center;
817
+ gap: var(--sqd-space-1);
818
+ margin-top: var(--sqd-space-4);
819
+ }
820
+
821
+ .sqd-pagination-link,
822
+ .sqd-pagination-current {
823
+ display: inline-flex;
824
+ align-items: center;
825
+ justify-content: center;
826
+ min-width: 36px;
827
+ height: 36px;
828
+ padding: 0 var(--sqd-space-2);
829
+ border-radius: var(--sqd-radius);
830
+ text-decoration: none;
831
+ font-weight: 500;
832
+ }
833
+
834
+ .sqd-pagination-link {
835
+ background-color: var(--sqd-bg-secondary);
836
+ color: var(--sqd-text-secondary);
837
+ border: 1px solid var(--sqd-border);
838
+ }
839
+
840
+ .sqd-pagination-link:hover {
841
+ background-color: var(--sqd-bg-tertiary);
842
+ color: var(--sqd-text-primary);
843
+ }
844
+
845
+ .sqd-pagination-current {
846
+ background-color: var(--sqd-primary);
847
+ color: #000;
848
+ }
849
+
850
+ .sqd-pagination-disabled {
851
+ opacity: 0.5;
852
+ cursor: not-allowed;
853
+ }
854
+
855
+ /* Empty State */
856
+ .sqd-empty-state {
857
+ text-align: center;
858
+ padding: var(--sqd-space-8);
859
+ }
860
+
861
+ .sqd-empty-icon {
862
+ width: 48px;
863
+ height: 48px;
864
+ color: var(--sqd-text-muted);
865
+ margin-bottom: var(--sqd-space-4);
866
+ }
867
+
868
+ .sqd-empty-title {
869
+ font-size: 18px;
870
+ font-weight: 600;
871
+ margin: 0 0 var(--sqd-space-2);
872
+ }
873
+
874
+ .sqd-empty-description {
875
+ color: var(--sqd-text-secondary);
876
+ margin: 0;
877
+ }
878
+
879
+ /* Job Details */
880
+ .sqd-detail-grid {
881
+ display: grid;
882
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
883
+ gap: var(--sqd-space-4);
884
+ }
885
+
886
+ .sqd-detail-item {
887
+ padding: var(--sqd-space-3) 0;
888
+ border-bottom: 1px solid var(--sqd-border);
889
+ }
890
+
891
+ .sqd-detail-label {
892
+ font-size: 12px;
893
+ color: var(--sqd-text-muted);
894
+ text-transform: uppercase;
895
+ letter-spacing: 0.05em;
896
+ margin-bottom: var(--sqd-space-1);
897
+ }
898
+
899
+ .sqd-detail-value {
900
+ font-size: 14px;
901
+ word-break: break-word;
902
+ }
903
+
904
+ .sqd-code {
905
+ font-family: "SF Mono", Monaco, Menlo, monospace;
906
+ font-size: 13px;
907
+ background-color: var(--sqd-bg-tertiary);
908
+ padding: var(--sqd-space-3);
909
+ border-radius: var(--sqd-radius);
910
+ overflow-x: auto;
911
+ white-space: pre-wrap;
912
+ word-break: break-word;
913
+ }
914
+
915
+ /* Queue/Worker Cards */
916
+ .sqd-card-grid {
917
+ display: grid;
918
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
919
+ gap: var(--sqd-space-4);
920
+ }
921
+
922
+ .sqd-queue-card,
923
+ .sqd-worker-card {
924
+ background-color: var(--sqd-bg-secondary);
925
+ border: 1px solid var(--sqd-border);
926
+ border-radius: var(--sqd-radius);
927
+ padding: var(--sqd-space-4);
928
+ }
929
+
930
+ .sqd-queue-header,
931
+ .sqd-worker-header {
932
+ display: flex;
933
+ align-items: center;
934
+ justify-content: space-between;
935
+ margin-bottom: var(--sqd-space-3);
936
+ }
937
+
938
+ .sqd-queue-name,
939
+ .sqd-worker-name {
940
+ font-weight: 600;
941
+ font-size: 16px;
942
+ }
943
+
944
+ .sqd-queue-stats,
945
+ .sqd-worker-stats {
946
+ display: flex;
947
+ gap: var(--sqd-space-4);
948
+ }
949
+
950
+ .sqd-queue-stat,
951
+ .sqd-worker-stat {
952
+ text-align: center;
953
+ }
954
+
955
+ .sqd-queue-stat-value,
956
+ .sqd-worker-stat-value {
957
+ font-size: 20px;
958
+ font-weight: 700;
959
+ }
960
+
961
+ .sqd-queue-stat-label,
962
+ .sqd-worker-stat-label {
963
+ font-size: 11px;
964
+ color: var(--sqd-text-muted);
965
+ text-transform: uppercase;
966
+ }
967
+
968
+ /* Filter Tabs */
969
+ .sqd-tabs {
970
+ display: flex;
971
+ gap: var(--sqd-space-1);
972
+ margin-bottom: var(--sqd-space-4);
973
+ border-bottom: 1px solid var(--sqd-border);
974
+ padding-bottom: var(--sqd-space-2);
975
+ overflow-x: auto;
976
+ }
977
+
978
+ .sqd-tab {
979
+ display: inline-flex;
980
+ align-items: center;
981
+ gap: var(--sqd-space-2);
982
+ padding: var(--sqd-space-2) var(--sqd-space-3);
983
+ color: var(--sqd-text-secondary);
984
+ text-decoration: none;
985
+ border-radius: var(--sqd-radius-sm);
986
+ white-space: nowrap;
987
+ transition: all 0.15s ease;
988
+ }
989
+
990
+ .sqd-tab:hover {
991
+ background-color: var(--sqd-bg-tertiary);
992
+ color: var(--sqd-text-primary);
993
+ }
994
+
995
+ .sqd-tab.active {
996
+ background-color: var(--sqd-primary-light);
997
+ color: var(--sqd-primary);
998
+ }
999
+
1000
+ .sqd-tab-count {
1001
+ background-color: var(--sqd-bg-tertiary);
1002
+ padding: var(--sqd-space-1) var(--sqd-space-2);
1003
+ border-radius: var(--sqd-radius-sm);
1004
+ font-size: 12px;
1005
+ font-weight: 600;
1006
+ }
1007
+
1008
+ .sqd-tab.active .sqd-tab-count {
1009
+ background-color: var(--sqd-primary);
1010
+ color: #000;
1011
+ }
1012
+
1013
+ /* Actions */
1014
+ .sqd-actions {
1015
+ display: flex;
1016
+ gap: var(--sqd-space-2);
1017
+ }
1018
+
1019
+ /* Responsive */
1020
+ @media (max-width: 768px) {
1021
+ .sqd-sidebar {
1022
+ width: 60px;
1023
+ }
1024
+
1025
+ .sqd-logo-text,
1026
+ .sqd-nav-label {
1027
+ display: none;
1028
+ }
1029
+
1030
+ .sqd-main {
1031
+ margin-left: 60px;
1032
+ }
1033
+
1034
+ .sqd-nav-link {
1035
+ justify-content: center;
1036
+ padding: var(--sqd-space-3);
1037
+ }
1038
+
1039
+ .sqd-header {
1040
+ flex-direction: column;
1041
+ align-items: flex-start;
1042
+ gap: var(--sqd-space-2);
1043
+ }
1044
+
1045
+ .sqd-stats-grid {
1046
+ grid-template-columns: repeat(2, 1fr);
1047
+ }
1048
+ }
1049
+ </style>
1050
+ </head>
1051
+ <body class="sqd-body sqd-theme-<%= sq_config.theme %>">
1052
+ <div class="sqd-layout">
1053
+ <!-- Sidebar -->
1054
+ <aside class="sqd-sidebar">
1055
+ <div class="sqd-sidebar-header">
1056
+ <a href="<%= main_app.root_path rescue root_path %>" class="sqd-logo-link">
1057
+ <svg class="sqd-logo" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1058
+ <path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
1059
+ </svg>
1060
+ <span class="sqd-logo-text">Job Harbor</span>
1061
+ </a>
1062
+ </div>
1063
+
1064
+ <nav class="sqd-nav">
1065
+ <ul class="sqd-nav-list">
1066
+ <li>
1067
+ <%= render JobHarbor::NavLinkComponent.new(
1068
+ path: root_path,
1069
+ label: "Dashboard",
1070
+ icon: "dashboard",
1071
+ active: controller_name == "dashboard"
1072
+ ) %>
1073
+ </li>
1074
+ <li>
1075
+ <%= render JobHarbor::NavLinkComponent.new(
1076
+ path: jobs_path,
1077
+ label: "Jobs",
1078
+ icon: "jobs",
1079
+ active: controller_name == "jobs"
1080
+ ) %>
1081
+ </li>
1082
+ <li>
1083
+ <%= render JobHarbor::NavLinkComponent.new(
1084
+ path: queues_path,
1085
+ label: "Queues",
1086
+ icon: "queues",
1087
+ active: controller_name == "queues"
1088
+ ) %>
1089
+ </li>
1090
+ <li>
1091
+ <%= render JobHarbor::NavLinkComponent.new(
1092
+ path: workers_path,
1093
+ label: "Workers",
1094
+ icon: "workers",
1095
+ active: controller_name == "workers",
1096
+ badge: nav_counts[:workers]
1097
+ ) %>
1098
+ </li>
1099
+ <% if sq_config.enable_recurring_tasks %>
1100
+ <li>
1101
+ <%= render JobHarbor::NavLinkComponent.new(
1102
+ path: recurring_tasks_path,
1103
+ label: "Recurring",
1104
+ icon: "recurring",
1105
+ active: controller_name == "recurring_tasks",
1106
+ badge: nav_counts[:recurring_tasks]
1107
+ ) %>
1108
+ </li>
1109
+ <% end %>
1110
+ </ul>
1111
+ </nav>
1112
+
1113
+ <div class="sqd-sidebar-footer">
1114
+ <div class="sqd-sidebar-footer-row">
1115
+ <%= render JobHarbor::ThemeToggleComponent.new %>
1116
+ <span class="sqd-version">v<%= JobHarbor::VERSION %></span>
1117
+ </div>
1118
+ </div>
1119
+ </aside>
1120
+
1121
+ <!-- Main Content -->
1122
+ <main class="sqd-main">
1123
+ <header class="sqd-header">
1124
+ <h1 class="sqd-page-title"><%= @page_title || "Dashboard" %></h1>
1125
+ <div class="sqd-header-actions">
1126
+ <%= render JobHarbor::RefreshSelectorComponent.new %>
1127
+ <% if content_for?(:header_actions) %>
1128
+ <%= yield :header_actions %>
1129
+ <% end %>
1130
+ </div>
1131
+ </header>
1132
+
1133
+ <% if flash[:notice] || flash[:alert] %>
1134
+ <div class="sqd-flash">
1135
+ <% if flash[:notice] %>
1136
+ <div class="sqd-flash-notice"><%= flash[:notice] %></div>
1137
+ <% end %>
1138
+ <% if flash[:alert] %>
1139
+ <div class="sqd-flash-alert"><%= flash[:alert] %></div>
1140
+ <% end %>
1141
+ </div>
1142
+ <% end %>
1143
+
1144
+ <div class="sqd-content">
1145
+ <%= yield %>
1146
+ </div>
1147
+ </main>
1148
+ </div>
1149
+
1150
+ <!-- Theme Toggle -->
1151
+ <script>
1152
+ (function() {
1153
+ var STORAGE_KEY = 'sqd-theme';
1154
+
1155
+ function getStoredTheme() {
1156
+ return localStorage.getItem(STORAGE_KEY);
1157
+ }
1158
+
1159
+ function setStoredTheme(theme) {
1160
+ localStorage.setItem(STORAGE_KEY, theme);
1161
+ }
1162
+
1163
+ function applyTheme(theme) {
1164
+ var body = document.body;
1165
+ if (theme === 'light') {
1166
+ body.classList.add('sqd-theme-light');
1167
+ } else {
1168
+ body.classList.remove('sqd-theme-light');
1169
+ }
1170
+ }
1171
+
1172
+ function getCurrentTheme() {
1173
+ return document.body.classList.contains('sqd-theme-light') ? 'light' : 'dark';
1174
+ }
1175
+
1176
+ function toggleTheme() {
1177
+ var newTheme = getCurrentTheme() === 'dark' ? 'light' : 'dark';
1178
+ applyTheme(newTheme);
1179
+ setStoredTheme(newTheme);
1180
+ }
1181
+
1182
+ // Apply stored theme on page load
1183
+ var storedTheme = getStoredTheme();
1184
+ if (storedTheme) {
1185
+ applyTheme(storedTheme);
1186
+ }
1187
+
1188
+ // Set up theme toggle click handler
1189
+ document.addEventListener('click', function(e) {
1190
+ var toggle = e.target.closest('.sqd-theme-toggle');
1191
+ if (toggle) {
1192
+ toggleTheme();
1193
+ }
1194
+ });
1195
+ })();
1196
+ </script>
1197
+
1198
+ <!-- Auto-Refresh Controller -->
1199
+ <script>
1200
+ (function() {
1201
+ var STORAGE_KEY = 'sqd-refresh-interval';
1202
+ var refreshTimer = null;
1203
+ var currentInterval = 0;
1204
+
1205
+ function getStoredInterval() {
1206
+ var stored = localStorage.getItem(STORAGE_KEY);
1207
+ if (stored !== null) {
1208
+ return parseInt(stored, 10);
1209
+ }
1210
+ return <%= sq_config.enable_real_time_updates ? sq_config.poll_interval : 0 %>;
1211
+ }
1212
+
1213
+ function setStoredInterval(interval) {
1214
+ localStorage.setItem(STORAGE_KEY, interval);
1215
+ }
1216
+
1217
+ function refreshContent() {
1218
+ var url = window.location.href;
1219
+ fetch(url, {
1220
+ headers: {
1221
+ 'Accept': 'text/html',
1222
+ 'X-Requested-With': 'XMLHttpRequest'
1223
+ }
1224
+ })
1225
+ .then(function(response) { return response.text(); })
1226
+ .then(function(html) {
1227
+ var parser = new DOMParser();
1228
+ var doc = parser.parseFromString(html, 'text/html');
1229
+ var newContent = doc.querySelector('.sqd-content');
1230
+ var currentContent = document.querySelector('.sqd-content');
1231
+ if (newContent && currentContent) {
1232
+ currentContent.innerHTML = newContent.innerHTML;
1233
+ }
1234
+ })
1235
+ .catch(function(error) {
1236
+ console.error('SQD refresh error:', error);
1237
+ });
1238
+ }
1239
+
1240
+ function startPolling(intervalSeconds) {
1241
+ stopPolling();
1242
+ currentInterval = intervalSeconds;
1243
+ if (intervalSeconds > 0) {
1244
+ refreshTimer = setInterval(refreshContent, intervalSeconds * 1000);
1245
+ }
1246
+ }
1247
+
1248
+ function stopPolling() {
1249
+ if (refreshTimer) {
1250
+ clearInterval(refreshTimer);
1251
+ refreshTimer = null;
1252
+ }
1253
+ }
1254
+
1255
+ function initRefreshSelector() {
1256
+ var select = document.querySelector('.sqd-refresh-select');
1257
+ if (select) {
1258
+ var interval = getStoredInterval();
1259
+ select.value = interval;
1260
+ startPolling(interval);
1261
+ }
1262
+ }
1263
+
1264
+ // Handle visibility changes
1265
+ document.addEventListener('visibilitychange', function() {
1266
+ if (document.hidden) {
1267
+ stopPolling();
1268
+ } else if (currentInterval > 0) {
1269
+ startPolling(currentInterval);
1270
+ }
1271
+ });
1272
+
1273
+ // Handle refresh interval changes
1274
+ document.addEventListener('change', function(e) {
1275
+ var select = e.target.closest('.sqd-refresh-select');
1276
+ if (select) {
1277
+ var interval = parseInt(select.value, 10);
1278
+ setStoredInterval(interval);
1279
+ startPolling(interval);
1280
+ }
1281
+ });
1282
+
1283
+ // Initialize on DOM ready
1284
+ if (document.readyState === 'loading') {
1285
+ document.addEventListener('DOMContentLoaded', initRefreshSelector);
1286
+ } else {
1287
+ initRefreshSelector();
1288
+ }
1289
+ })();
1290
+ </script>
1291
+
1292
+ <!-- Per-Page Selector and Filter Selector -->
1293
+ <script>
1294
+ (function() {
1295
+ document.addEventListener('change', function(e) {
1296
+ // Per-page selector
1297
+ var perPageSelect = e.target.closest('.sqd-per-page-select');
1298
+ if (perPageSelect) {
1299
+ var url = perPageSelect.value;
1300
+ if (url) {
1301
+ window.location.href = url;
1302
+ }
1303
+ return;
1304
+ }
1305
+
1306
+ // Filter selector
1307
+ var filterSelect = e.target.closest('.sqd-filter-select');
1308
+ if (filterSelect) {
1309
+ var url = filterSelect.value;
1310
+ if (url) {
1311
+ window.location.href = url;
1312
+ }
1313
+ }
1314
+ });
1315
+ })();
1316
+ </script>
1317
+
1318
+ <!-- Chart Rendering -->
1319
+ <script>
1320
+ (function() {
1321
+ function initChart() {
1322
+ var chartContainer = document.querySelector('.sqd-chart');
1323
+ if (!chartContainer) return;
1324
+
1325
+ var canvas = document.getElementById('sqd-job-chart');
1326
+ if (!canvas) return;
1327
+
1328
+ var data = JSON.parse(chartContainer.dataset.chartData || '{}');
1329
+ if (!data.labels || data.labels.length === 0) return;
1330
+
1331
+ var ctx = canvas.getContext('2d');
1332
+ var rect = chartContainer.getBoundingClientRect();
1333
+ canvas.width = rect.width * 2;
1334
+ canvas.height = 400;
1335
+
1336
+ var isDark = !document.body.classList.contains('sqd-theme-light');
1337
+ var gridColor = isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)';
1338
+ var textColor = isDark ? '#9ca3af' : '#6b7280';
1339
+
1340
+ var colors = {
1341
+ completed: '#10b981',
1342
+ failed: '#ef4444',
1343
+ enqueued: '#3b82f6'
1344
+ };
1345
+
1346
+ // Find max value for scaling
1347
+ var allValues = [].concat(data.completed || [], data.failed || [], data.enqueued || []);
1348
+ var maxVal = Math.max.apply(null, allValues) || 10;
1349
+ maxVal = Math.ceil(maxVal * 1.1); // Add 10% padding
1350
+
1351
+ var padding = { top: 20, right: 20, bottom: 40, left: 50 };
1352
+ var chartWidth = canvas.width - padding.left - padding.right;
1353
+ var chartHeight = canvas.height - padding.top - padding.bottom;
1354
+
1355
+ // Clear canvas
1356
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
1357
+
1358
+ // Draw grid lines
1359
+ ctx.strokeStyle = gridColor;
1360
+ ctx.lineWidth = 1;
1361
+ var gridLines = 5;
1362
+ for (var i = 0; i <= gridLines; i++) {
1363
+ var y = padding.top + (chartHeight / gridLines) * i;
1364
+ ctx.beginPath();
1365
+ ctx.moveTo(padding.left, y);
1366
+ ctx.lineTo(canvas.width - padding.right, y);
1367
+ ctx.stroke();
1368
+
1369
+ // Y-axis labels
1370
+ var value = Math.round(maxVal - (maxVal / gridLines) * i);
1371
+ ctx.fillStyle = textColor;
1372
+ ctx.font = '20px -apple-system, sans-serif';
1373
+ ctx.textAlign = 'right';
1374
+ ctx.fillText(value, padding.left - 10, y + 6);
1375
+ }
1376
+
1377
+ // X-axis labels
1378
+ ctx.textAlign = 'center';
1379
+ var labelStep = Math.ceil(data.labels.length / 8);
1380
+ for (var i = 0; i < data.labels.length; i += labelStep) {
1381
+ var x = padding.left + (chartWidth / (data.labels.length - 1)) * i;
1382
+ ctx.fillText(data.labels[i], x, canvas.height - 10);
1383
+ }
1384
+
1385
+ // Draw lines
1386
+ function drawLine(series, color) {
1387
+ if (!series || series.length === 0) return;
1388
+ ctx.strokeStyle = color;
1389
+ ctx.lineWidth = 3;
1390
+ ctx.beginPath();
1391
+ for (var i = 0; i < series.length; i++) {
1392
+ var x = padding.left + (chartWidth / (series.length - 1)) * i;
1393
+ var y = padding.top + chartHeight - (series[i] / maxVal) * chartHeight;
1394
+ if (i === 0) {
1395
+ ctx.moveTo(x, y);
1396
+ } else {
1397
+ ctx.lineTo(x, y);
1398
+ }
1399
+ }
1400
+ ctx.stroke();
1401
+
1402
+ // Draw dots
1403
+ ctx.fillStyle = color;
1404
+ for (var i = 0; i < series.length; i++) {
1405
+ var x = padding.left + (chartWidth / (series.length - 1)) * i;
1406
+ var y = padding.top + chartHeight - (series[i] / maxVal) * chartHeight;
1407
+ ctx.beginPath();
1408
+ ctx.arc(x, y, 4, 0, Math.PI * 2);
1409
+ ctx.fill();
1410
+ }
1411
+ }
1412
+
1413
+ drawLine(data.completed, colors.completed);
1414
+ drawLine(data.failed, colors.failed);
1415
+ drawLine(data.enqueued, colors.enqueued);
1416
+ }
1417
+
1418
+ // Initialize on DOM ready
1419
+ if (document.readyState === 'loading') {
1420
+ document.addEventListener('DOMContentLoaded', initChart);
1421
+ } else {
1422
+ initChart();
1423
+ }
1424
+
1425
+ // Re-render on theme change
1426
+ document.addEventListener('click', function(e) {
1427
+ if (e.target.closest('.sqd-theme-toggle')) {
1428
+ setTimeout(initChart, 100);
1429
+ }
1430
+ });
1431
+ })();
1432
+ </script>
1433
+ </body>
1434
+ </html>