rails_orbit 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 (47) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +11 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +241 -0
  5. data/app/assets/javascripts/rails_orbit/application.js +232 -0
  6. data/app/assets/stylesheets/rails_orbit/application.css +536 -0
  7. data/app/controllers/rails_orbit/application_controller.rb +26 -0
  8. data/app/controllers/rails_orbit/dashboard_controller.rb +84 -0
  9. data/app/controllers/rails_orbit/stream_controller.rb +55 -0
  10. data/app/helpers/rails_orbit/dashboard_helper.rb +44 -0
  11. data/app/helpers/rails_orbit/icon_helper.rb +19 -0
  12. data/app/jobs/rails_orbit/application_job.rb +4 -0
  13. data/app/jobs/rails_orbit/retention_job.rb +22 -0
  14. data/app/models/rails_orbit/application_record.rb +6 -0
  15. data/app/models/rails_orbit/metric.rb +97 -0
  16. data/app/views/layouts/rails_orbit/application.html.erb +20 -0
  17. data/app/views/rails_orbit/dashboard/_overview_card.html.erb +17 -0
  18. data/app/views/rails_orbit/dashboard/cache.html.erb +58 -0
  19. data/app/views/rails_orbit/dashboard/errors.html.erb +54 -0
  20. data/app/views/rails_orbit/dashboard/jobs.html.erb +64 -0
  21. data/app/views/rails_orbit/dashboard/overview.html.erb +67 -0
  22. data/app/views/rails_orbit/shared/_delta.html.erb +14 -0
  23. data/app/views/rails_orbit/shared/_nav.html.erb +26 -0
  24. data/app/views/rails_orbit/shared/_range_picker.html.erb +5 -0
  25. data/app/views/rails_orbit/stream/_cache_stats.html.erb +9 -0
  26. data/app/views/rails_orbit/stream/_error_count.html.erb +1 -0
  27. data/app/views/rails_orbit/stream/_queue_stats.html.erb +8 -0
  28. data/app/views/rails_orbit/stream/index.turbo_stream.erb +11 -0
  29. data/config/routes.rb +9 -0
  30. data/lib/generators/rails_orbit/install_generator.rb +55 -0
  31. data/lib/generators/rails_orbit/templates/create_orbit_metrics.rb.erb +14 -0
  32. data/lib/generators/rails_orbit/templates/initializer.rb +31 -0
  33. data/lib/rails_orbit/configuration.rb +73 -0
  34. data/lib/rails_orbit/database_setup.rb +87 -0
  35. data/lib/rails_orbit/engine.rb +80 -0
  36. data/lib/rails_orbit/instrumentation.rb +83 -0
  37. data/lib/rails_orbit/kamal/config_reader.rb +32 -0
  38. data/lib/rails_orbit/kamal/poller.rb +64 -0
  39. data/lib/rails_orbit/kamal/stats_collector.rb +42 -0
  40. data/lib/rails_orbit/metric_writer.rb +37 -0
  41. data/lib/rails_orbit/time_range.rb +39 -0
  42. data/lib/rails_orbit/version.rb +3 -0
  43. data/lib/rails_orbit.rb +24 -0
  44. data/lib/tasks/rails_orbit.rake +60 -0
  45. data/public/assets/rails_orbit/application.css +536 -0
  46. data/public/assets/rails_orbit/application.js +237 -0
  47. metadata +264 -0
@@ -0,0 +1,536 @@
1
+ /* rails_orbit — self-contained dashboard styles */
2
+ :root {
3
+ --orbit-bg: #0a0a0f;
4
+ --orbit-surface: #12121a;
5
+ --orbit-surface-2: #1a1a26;
6
+ --orbit-surface-3: #1e1e2c;
7
+ --orbit-border: #2a2a3a;
8
+ --orbit-border-hover:#3a3a50;
9
+ --orbit-text: #e4e4ec;
10
+ --orbit-text-muted: #8888a0;
11
+ --orbit-primary: #6366f1;
12
+ --orbit-primary-dim: #4f46e5;
13
+ --orbit-danger: #ef4444;
14
+ --orbit-danger-dim: rgba(239, 68, 68, 0.12);
15
+ --orbit-success: #22c55e;
16
+ --orbit-success-dim: rgba(34, 197, 94, 0.12);
17
+ --orbit-warning: #f59e0b;
18
+ --orbit-warning-dim: rgba(245, 158, 11, 0.12);
19
+ --orbit-radius: 0.5rem;
20
+ --orbit-radius-lg: 0.75rem;
21
+ --orbit-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
22
+ --orbit-mono: "SF Mono", "Fira Code", "Cascadia Code", "Consolas", monospace;
23
+ }
24
+
25
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
26
+
27
+ .orbit-body {
28
+ font-family: var(--orbit-font);
29
+ background: var(--orbit-bg);
30
+ color: var(--orbit-text);
31
+ min-height: 100vh;
32
+ line-height: 1.5;
33
+ -webkit-font-smoothing: antialiased;
34
+ }
35
+
36
+ /* ── Nav ─────────────────────────────────────────────────── */
37
+ .orbit-nav {
38
+ background: var(--orbit-surface);
39
+ border-bottom: 1px solid var(--orbit-border);
40
+ position: sticky;
41
+ top: 0;
42
+ z-index: 50;
43
+ }
44
+ .orbit-nav__inner {
45
+ max-width: 80rem;
46
+ margin: 0 auto;
47
+ padding: 0 1.5rem;
48
+ display: flex;
49
+ align-items: center;
50
+ height: 3.25rem;
51
+ gap: 2rem;
52
+ }
53
+ .orbit-nav__brand {
54
+ display: flex;
55
+ align-items: center;
56
+ gap: 0.5rem;
57
+ flex-shrink: 0;
58
+ }
59
+ .orbit-nav__logo {
60
+ width: 1.25rem;
61
+ height: 1.25rem;
62
+ color: var(--orbit-primary);
63
+ }
64
+ .orbit-nav__title {
65
+ font-weight: 700;
66
+ font-size: 0.95rem;
67
+ letter-spacing: -0.01em;
68
+ }
69
+ .orbit-nav__links {
70
+ display: flex;
71
+ gap: 0.125rem;
72
+ }
73
+ .orbit-nav__link {
74
+ color: var(--orbit-text-muted);
75
+ text-decoration: none;
76
+ padding: 0.35rem 0.65rem;
77
+ border-radius: var(--orbit-radius);
78
+ font-size: 0.8rem;
79
+ font-weight: 500;
80
+ cursor: pointer;
81
+ display: inline-flex;
82
+ align-items: center;
83
+ gap: 0.35rem;
84
+ transition: color 0.15s, background 0.15s;
85
+ }
86
+ .orbit-nav__link:hover {
87
+ color: var(--orbit-text);
88
+ background: var(--orbit-surface-2);
89
+ }
90
+ .orbit-nav__link:focus-visible {
91
+ outline: 2px solid var(--orbit-primary);
92
+ outline-offset: 2px;
93
+ }
94
+ .orbit-nav__link--active {
95
+ color: var(--orbit-text);
96
+ background: var(--orbit-primary-dim);
97
+ }
98
+ .orbit-nav__icon {
99
+ width: 0.875rem;
100
+ height: 0.875rem;
101
+ flex-shrink: 0;
102
+ }
103
+
104
+ /* ── Main ────────────────────────────────────────────────── */
105
+ .orbit-main {
106
+ max-width: 80rem;
107
+ margin: 0 auto;
108
+ padding: 1.25rem 1.5rem;
109
+ }
110
+
111
+ /* ── Page ────────────────────────────────────────────────── */
112
+ .orbit-page-header {
113
+ display: flex;
114
+ align-items: center;
115
+ justify-content: space-between;
116
+ margin-bottom: 1.25rem;
117
+ }
118
+ .orbit-page-header .orbit-page__title { margin-bottom: 0; }
119
+ .orbit-page__title {
120
+ font-size: 1.25rem;
121
+ font-weight: 700;
122
+ margin-bottom: 1.25rem;
123
+ letter-spacing: -0.02em;
124
+ }
125
+
126
+ /* ── Updated indicator ───────────────────────────────────── */
127
+ .orbit-updated {
128
+ font-size: 0.7rem;
129
+ color: var(--orbit-text-muted);
130
+ font-variant-numeric: tabular-nums;
131
+ }
132
+
133
+ /* ── Grid ────────────────────────────────────────────────── */
134
+ .orbit-grid {
135
+ display: grid;
136
+ gap: 0.75rem;
137
+ margin-bottom: 1rem;
138
+ }
139
+ .orbit-grid--3 { grid-template-columns: repeat(3, 1fr); }
140
+ .orbit-grid--4 { grid-template-columns: repeat(4, 1fr); }
141
+
142
+ /* ── Card ────────────────────────────────────────────────── */
143
+ .orbit-card {
144
+ background: var(--orbit-surface);
145
+ border: 1px solid var(--orbit-border);
146
+ border-radius: var(--orbit-radius-lg);
147
+ padding: 1rem 1.125rem;
148
+ transition: border-color 0.15s, box-shadow 0.15s;
149
+ }
150
+ .orbit-card:hover {
151
+ border-color: var(--orbit-border-hover);
152
+ }
153
+ .orbit-card--compact { padding: 0.875rem 1rem; }
154
+ .orbit-card--wide {
155
+ grid-column: 1 / -1;
156
+ background: var(--orbit-surface);
157
+ border: 1px solid var(--orbit-border);
158
+ border-radius: var(--orbit-radius-lg);
159
+ padding: 1rem 1.125rem;
160
+ }
161
+ .orbit-card--bordered { border-left-width: 3px; }
162
+ .orbit-card--border-primary { border-left-color: var(--orbit-primary); }
163
+ .orbit-card--border-success { border-left-color: var(--orbit-success); }
164
+ .orbit-card--border-warning { border-left-color: var(--orbit-warning); }
165
+ .orbit-card--border-danger { border-left-color: var(--orbit-danger); }
166
+
167
+ .orbit-card__header {
168
+ display: flex;
169
+ align-items: center;
170
+ justify-content: space-between;
171
+ margin-bottom: 0.5rem;
172
+ }
173
+ .orbit-card__label {
174
+ font-size: 0.675rem;
175
+ font-weight: 600;
176
+ text-transform: uppercase;
177
+ letter-spacing: 0.05em;
178
+ color: var(--orbit-text-muted);
179
+ margin-bottom: 0.35rem;
180
+ display: flex;
181
+ align-items: center;
182
+ gap: 0.375rem;
183
+ }
184
+ .orbit-card__header .orbit-card__label { margin-bottom: 0; }
185
+ .orbit-card__value {
186
+ font-size: 1.5rem;
187
+ font-weight: 700;
188
+ letter-spacing: -0.02em;
189
+ font-variant-numeric: tabular-nums;
190
+ }
191
+ .orbit-card__value--danger { color: var(--orbit-danger); }
192
+ .orbit-card__value--warning { color: var(--orbit-warning); }
193
+ .orbit-card__value--success { color: var(--orbit-success); }
194
+ .orbit-card__unit {
195
+ font-size: 0.65em;
196
+ font-weight: 500;
197
+ color: var(--orbit-text-muted);
198
+ margin-left: 0.1em;
199
+ }
200
+ .orbit-card__footer {
201
+ margin-top: 0.5rem;
202
+ display: flex;
203
+ gap: 0.75rem;
204
+ flex-wrap: wrap;
205
+ }
206
+ .orbit-card__detail {
207
+ font-size: 0.7rem;
208
+ color: var(--orbit-text-muted);
209
+ font-variant-numeric: tabular-nums;
210
+ }
211
+ .orbit-card__detail--success { color: var(--orbit-success); }
212
+ .orbit-card__detail--danger { color: var(--orbit-danger); }
213
+
214
+ /* ── Icons ───────────────────────────────────────────────── */
215
+ .orbit-icon {
216
+ width: 0.875rem;
217
+ height: 0.875rem;
218
+ flex-shrink: 0;
219
+ }
220
+
221
+ /* ── Delta indicator ─────────────────────────────────────── */
222
+ .orbit-delta {
223
+ display: inline-flex;
224
+ align-items: center;
225
+ gap: 0.2rem;
226
+ font-size: 0.7rem;
227
+ font-weight: 600;
228
+ font-variant-numeric: tabular-nums;
229
+ border-radius: 0.25rem;
230
+ padding: 0.125rem 0.375rem;
231
+ }
232
+ .orbit-delta--up { color: var(--orbit-danger); background: var(--orbit-danger-dim); }
233
+ .orbit-delta--down { color: var(--orbit-success); background: var(--orbit-success-dim); }
234
+ .orbit-delta--flat { color: var(--orbit-text-muted); background: var(--orbit-surface-2); }
235
+ .orbit-delta__icon { width: 0.5rem; height: 0.5rem; }
236
+
237
+ /* ── Stats (stream partials) ─────────────────────────────── */
238
+ .orbit-stat-group {
239
+ display: flex;
240
+ align-items: baseline;
241
+ gap: 0.4rem;
242
+ }
243
+ .orbit-stat {
244
+ font-size: 1.5rem;
245
+ font-weight: 700;
246
+ letter-spacing: -0.02em;
247
+ font-variant-numeric: tabular-nums;
248
+ }
249
+ .orbit-stat--label {
250
+ font-size: 0.75rem;
251
+ font-weight: 500;
252
+ color: var(--orbit-text-muted);
253
+ }
254
+ .orbit-stat--success { color: var(--orbit-success); }
255
+ .orbit-stat--warning { color: var(--orbit-warning); }
256
+ .orbit-stat--danger { color: var(--orbit-danger); }
257
+ .orbit-stat-row {
258
+ display: flex;
259
+ gap: 0.5rem;
260
+ margin-top: 0.375rem;
261
+ flex-wrap: wrap;
262
+ }
263
+ .orbit-stat-pill {
264
+ font-size: 0.65rem;
265
+ font-weight: 600;
266
+ color: var(--orbit-text-muted);
267
+ background: var(--orbit-surface-2);
268
+ padding: 0.15rem 0.45rem;
269
+ border-radius: 0.25rem;
270
+ font-variant-numeric: tabular-nums;
271
+ }
272
+ .orbit-stat-pill--danger { color: var(--orbit-danger); background: var(--orbit-danger-dim); }
273
+ .orbit-stat-pill--success { color: var(--orbit-success); background: var(--orbit-success-dim); }
274
+
275
+ /* ── Range Picker ────────────────────────────────────────── */
276
+ .orbit-range-picker {
277
+ display: flex;
278
+ gap: 0.125rem;
279
+ background: var(--orbit-surface-2);
280
+ padding: 0.2rem;
281
+ border-radius: var(--orbit-radius);
282
+ }
283
+ .orbit-range-picker__btn {
284
+ color: var(--orbit-text-muted);
285
+ text-decoration: none;
286
+ padding: 0.25rem 0.55rem;
287
+ border-radius: 0.25rem;
288
+ font-size: 0.7rem;
289
+ font-weight: 600;
290
+ cursor: pointer;
291
+ transition: color 0.15s, background 0.15s;
292
+ font-variant-numeric: tabular-nums;
293
+ }
294
+ .orbit-range-picker__btn:hover {
295
+ color: var(--orbit-text);
296
+ background: var(--orbit-surface-3);
297
+ }
298
+ .orbit-range-picker__btn--active {
299
+ color: var(--orbit-text);
300
+ background: var(--orbit-primary-dim);
301
+ }
302
+ .orbit-range-picker__btn:focus-visible {
303
+ outline: 2px solid var(--orbit-primary);
304
+ outline-offset: 1px;
305
+ }
306
+ .orbit-page-header__right {
307
+ display: flex;
308
+ align-items: center;
309
+ gap: 0.75rem;
310
+ }
311
+
312
+ /* ── SVG Chart ───────────────────────────────────────────── */
313
+ .orbit-chart-wrap { position: relative; }
314
+ .orbit-chart {
315
+ width: 100%;
316
+ height: 5.5rem;
317
+ display: block;
318
+ cursor: crosshair;
319
+ }
320
+ .orbit-chart__labels {
321
+ display: flex;
322
+ justify-content: space-between;
323
+ margin-top: 0.35rem;
324
+ }
325
+ .orbit-chart__label {
326
+ font-size: 0.65rem;
327
+ color: var(--orbit-text-muted);
328
+ font-variant-numeric: tabular-nums;
329
+ font-family: var(--orbit-mono);
330
+ }
331
+ .orbit-chart__label--current {
332
+ color: var(--orbit-primary);
333
+ font-weight: 600;
334
+ }
335
+ .orbit-chart__dot {
336
+ pointer-events: none;
337
+ transition: cx 0.05s, cy 0.05s;
338
+ }
339
+ .orbit-chart__vline { pointer-events: none; }
340
+ .orbit-chart__hitzone rect { cursor: crosshair; }
341
+ .orbit-chart__tooltip {
342
+ position: absolute;
343
+ top: -0.25rem;
344
+ padding: 0.25rem 0.5rem;
345
+ font-size: 0.65rem;
346
+ font-weight: 600;
347
+ font-family: var(--orbit-mono);
348
+ font-variant-numeric: tabular-nums;
349
+ color: var(--orbit-text);
350
+ background: var(--orbit-surface-3);
351
+ border: 1px solid var(--orbit-border);
352
+ border-radius: 0.25rem;
353
+ white-space: nowrap;
354
+ pointer-events: none;
355
+ z-index: 10;
356
+ }
357
+ .orbit-grid--1 { grid-template-columns: 1fr; }
358
+
359
+ /* ── Hit rate bar ────────────────────────────────────────── */
360
+ .orbit-hit-bar {
361
+ height: 0.35rem;
362
+ background: var(--orbit-danger-dim);
363
+ border-radius: 999px;
364
+ margin-top: 0.5rem;
365
+ overflow: hidden;
366
+ }
367
+ .orbit-hit-bar__fill {
368
+ height: 100%;
369
+ background: var(--orbit-success);
370
+ border-radius: 999px;
371
+ transition: width 0.3s ease;
372
+ }
373
+
374
+ /* ── Table ───────────────────────────────────────────────── */
375
+ .orbit-table-wrap {
376
+ overflow-x: auto;
377
+ border: 1px solid var(--orbit-border);
378
+ border-radius: var(--orbit-radius-lg);
379
+ }
380
+ .orbit-table {
381
+ width: 100%;
382
+ border-collapse: collapse;
383
+ font-size: 0.8rem;
384
+ }
385
+ .orbit-table--compact { font-size: 0.775rem; }
386
+ .orbit-table th {
387
+ text-align: left;
388
+ padding: 0.625rem 0.875rem;
389
+ font-weight: 600;
390
+ font-size: 0.675rem;
391
+ text-transform: uppercase;
392
+ letter-spacing: 0.05em;
393
+ color: var(--orbit-text-muted);
394
+ background: var(--orbit-surface);
395
+ border-bottom: 1px solid var(--orbit-border);
396
+ }
397
+ .orbit-table td {
398
+ padding: 0.625rem 0.875rem;
399
+ border-bottom: 1px solid var(--orbit-border);
400
+ }
401
+ .orbit-table tbody tr:last-child td { border-bottom: none; }
402
+ .orbit-table__right { text-align: right; }
403
+ .orbit-table__mono {
404
+ font-family: var(--orbit-mono);
405
+ font-size: 0.775rem;
406
+ font-variant-numeric: tabular-nums;
407
+ }
408
+ .orbit-table__queue {
409
+ font-weight: 600;
410
+ color: var(--orbit-primary);
411
+ }
412
+ .orbit-table__danger {
413
+ color: var(--orbit-danger);
414
+ font-weight: 600;
415
+ }
416
+ .orbit-table__unit {
417
+ font-size: 0.65em;
418
+ color: var(--orbit-text-muted);
419
+ margin-left: 0.1em;
420
+ }
421
+ .orbit-table__row--danger {
422
+ background: var(--orbit-danger-dim);
423
+ }
424
+
425
+ /* ── Error groups ────────────────────────────────────────── */
426
+ .orbit-error-group {
427
+ margin-bottom: 1rem;
428
+ }
429
+ .orbit-error-group__header {
430
+ display: flex;
431
+ align-items: center;
432
+ justify-content: space-between;
433
+ padding: 0.625rem 0;
434
+ }
435
+ .orbit-error-group__title {
436
+ display: flex;
437
+ align-items: center;
438
+ gap: 0.5rem;
439
+ }
440
+ .orbit-error-group__class {
441
+ font-weight: 700;
442
+ font-size: 0.85rem;
443
+ color: var(--orbit-danger);
444
+ font-family: var(--orbit-mono);
445
+ }
446
+ .orbit-error-group__badge {
447
+ font-size: 0.65rem;
448
+ font-weight: 700;
449
+ padding: 0.1rem 0.4rem;
450
+ border-radius: 0.25rem;
451
+ background: var(--orbit-danger-dim);
452
+ color: var(--orbit-danger);
453
+ font-variant-numeric: tabular-nums;
454
+ }
455
+ .orbit-error-group__resolved {
456
+ font-size: 0.6rem;
457
+ font-weight: 600;
458
+ text-transform: uppercase;
459
+ letter-spacing: 0.05em;
460
+ padding: 0.1rem 0.4rem;
461
+ border-radius: 0.25rem;
462
+ background: var(--orbit-success-dim);
463
+ color: var(--orbit-success);
464
+ }
465
+ .orbit-error-group__meta {
466
+ font-size: 0.7rem;
467
+ color: var(--orbit-text-muted);
468
+ }
469
+
470
+ /* ── Empty state ─────────────────────────────────────────── */
471
+ .orbit-empty {
472
+ text-align: center;
473
+ padding: 2.5rem 1rem;
474
+ color: var(--orbit-text-muted);
475
+ font-size: 0.875rem;
476
+ background: var(--orbit-surface);
477
+ border: 1px solid var(--orbit-border);
478
+ border-radius: var(--orbit-radius-lg);
479
+ }
480
+ .orbit-empty__icon {
481
+ width: 2.5rem;
482
+ height: 2.5rem;
483
+ margin: 0 auto 0.75rem;
484
+ color: var(--orbit-border-hover);
485
+ display: block;
486
+ }
487
+ .orbit-empty__hint {
488
+ font-size: 0.75rem;
489
+ color: var(--orbit-border-hover);
490
+ margin-top: 0.35rem;
491
+ }
492
+
493
+ /* ── Flash ───────────────────────────────────────────────── */
494
+ .orbit-flash {
495
+ padding: 0.625rem 0.875rem;
496
+ border-radius: var(--orbit-radius);
497
+ margin-bottom: 0.75rem;
498
+ font-size: 0.8rem;
499
+ font-weight: 500;
500
+ }
501
+ .orbit-flash--warning {
502
+ background: var(--orbit-warning-dim);
503
+ border: 1px solid rgba(245, 158, 11, 0.25);
504
+ color: var(--orbit-warning);
505
+ }
506
+
507
+ /* ── Focus ───────────────────────────────────────────────── */
508
+ a:focus-visible, button:focus-visible {
509
+ outline: 2px solid var(--orbit-primary);
510
+ outline-offset: 2px;
511
+ }
512
+
513
+ /* ── Reduced motion ──────────────────────────────────────── */
514
+ @media (prefers-reduced-motion: reduce) {
515
+ *, *::before, *::after {
516
+ transition-duration: 0.01ms !important;
517
+ }
518
+ }
519
+
520
+ /* ── Responsive ──────────────────────────────────────────── */
521
+ @media (max-width: 1024px) {
522
+ .orbit-grid--4 { grid-template-columns: repeat(2, 1fr); }
523
+ }
524
+ @media (max-width: 768px) {
525
+ .orbit-grid--3 { grid-template-columns: 1fr; }
526
+ .orbit-nav__inner { padding: 0 1rem; gap: 1rem; }
527
+ .orbit-nav__links { gap: 0; overflow-x: auto; }
528
+ }
529
+ @media (max-width: 640px) {
530
+ .orbit-main { padding: 1rem; }
531
+ .orbit-grid--4 { grid-template-columns: 1fr; }
532
+ .orbit-card__value, .orbit-stat { font-size: 1.125rem; }
533
+ .orbit-page-header { flex-direction: column; align-items: flex-start; gap: 0.5rem; }
534
+ .orbit-page-header__right { flex-direction: column; align-items: flex-start; gap: 0.25rem; }
535
+ .orbit-error-group__header { flex-direction: column; align-items: flex-start; gap: 0.25rem; }
536
+ }
@@ -0,0 +1,26 @@
1
+ module RailsOrbit
2
+ class ApplicationController < ActionController::Base
3
+ before_action :authenticate_orbit!
4
+ before_action :set_security_headers
5
+ layout "rails_orbit/application"
6
+
7
+ private
8
+
9
+ def authenticate_orbit!
10
+ RailsOrbit.configuration.auth_block&.call(self)
11
+ end
12
+
13
+ def set_security_headers
14
+ response.headers["X-Frame-Options"] = "DENY"
15
+ response.headers["X-Content-Type-Options"] = "nosniff"
16
+ response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
17
+ response.headers["Content-Security-Policy"] = "default-src 'none'; " \
18
+ "style-src 'self' 'unsafe-inline'; " \
19
+ "script-src 'self' 'unsafe-inline'; " \
20
+ "img-src 'self' data:; " \
21
+ "connect-src 'self'; " \
22
+ "font-src 'self'; " \
23
+ "frame-ancestors 'none'"
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,84 @@
1
+ module RailsOrbit
2
+ class DashboardController < ApplicationController
3
+ before_action :set_time_range
4
+
5
+ def overview
6
+ sums = Metric.sums_since(@time_range.since)
7
+
8
+ @enqueued_count = sums["solid_queue.enqueued"].to_i
9
+ @failed_jobs_count = sums["solid_queue.failed"].to_i
10
+ @retried_count = sums["solid_queue.retried"].to_i
11
+ @discarded_count = sums["solid_queue.discarded"].to_i
12
+ @avg_duration = Metric.since(@time_range.since).for_key("solid_queue.performed_ms").average(:value)&.round(1) || 0
13
+
14
+ @cache_hits = sums["solid_cache.read_hit"].to_i
15
+ @cache_misses = sums["solid_cache.read_miss"].to_i
16
+ @cache_writes = sums["solid_cache.write"].to_i
17
+ @cache_hit_rate = Metric.hit_rate(@cache_hits, @cache_misses)
18
+
19
+ @error_count = sums["solid_errors.recorded"].to_i
20
+
21
+ window = @time_range.delta_window
22
+ @enqueued_delta = Metric.compute_delta("solid_queue.enqueued", window: window)
23
+ @failed_delta = Metric.compute_delta("solid_queue.failed", window: window)
24
+ @error_delta = Metric.compute_delta("solid_errors.recorded", window: window)
25
+ @cache_delta = Metric.compute_hit_rate_delta(window: window)
26
+
27
+ bucket = @time_range.bucket_minutes
28
+ @job_chart = Metric.bucketed_series("solid_queue.performed_ms", since: @time_range.since, bucket_minutes: bucket, aggregate: :avg)
29
+ @cache_chart = Metric.bucketed_hit_rate_series(since: @time_range.since, bucket_minutes: bucket)
30
+ @error_chart = Metric.bucketed_series("solid_errors.recorded", since: @time_range.since, bucket_minutes: bucket)
31
+ end
32
+
33
+ def jobs
34
+ sums = Metric.sums_since(@time_range.since)
35
+
36
+ @total_enqueued = sums["solid_queue.enqueued"].to_i
37
+ @total_failed = sums["solid_queue.failed"].to_i
38
+ @total_discarded = sums["solid_queue.discarded"].to_i
39
+ @avg_duration = Metric.since(@time_range.since).for_key("solid_queue.performed_ms").average(:value)&.round(1) || 0
40
+
41
+ @by_queue = Metric.since(@time_range.since)
42
+ .where(key: %w[solid_queue.enqueued solid_queue.performed_ms solid_queue.failed solid_queue.retried solid_queue.discarded])
43
+ .group(:dimension, :key)
44
+ .sum(:value)
45
+ end
46
+
47
+ def cache
48
+ sums = Metric.sums_since(@time_range.since)
49
+
50
+ @hits = sums["solid_cache.read_hit"].to_i
51
+ @misses = sums["solid_cache.read_miss"].to_i
52
+ @reads = @hits + @misses
53
+ @hit_rate = Metric.hit_rate(@hits, @misses)
54
+ @writes = sums["solid_cache.write"].to_i
55
+ @deletes = sums["solid_cache.delete"].to_i
56
+ @fetch_hit = sums["solid_cache.fetch_hit"].to_i
57
+ end
58
+
59
+ def errors
60
+ if defined?(SolidErrors)
61
+ all_errors = SolidErrors::Error.where(created_at: @time_range.since..).order(created_at: :desc).limit(200)
62
+ @grouped_errors = all_errors.group_by(&:exception_class).map do |klass, records|
63
+ {
64
+ exception_class: klass,
65
+ count: records.size,
66
+ last_seen: records.first.created_at,
67
+ resolved: records.first.respond_to?(:resolved_at) && records.first.resolved_at.present?,
68
+ records: records.first(5),
69
+ }
70
+ end.sort_by { |g| -g[:count] }
71
+ else
72
+ @grouped_errors = []
73
+ flash.now[:warning] = "solid_errors is not installed or not configured."
74
+ end
75
+ end
76
+
77
+ private
78
+
79
+ def set_time_range
80
+ @time_range = TimeRange.new(params[:range])
81
+ @range_key = @time_range.key
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,55 @@
1
+ module RailsOrbit
2
+ class StreamController < ApplicationController
3
+ def index
4
+ respond_to do |format|
5
+ format.turbo_stream do
6
+ stats = fetch_all_stats
7
+ render turbo_stream: [
8
+ turbo_stream.update("orbit-queue-stats",
9
+ partial: "rails_orbit/stream/queue_stats",
10
+ locals: { data: stats[:queue] }),
11
+ turbo_stream.update("orbit-cache-stats",
12
+ partial: "rails_orbit/stream/cache_stats",
13
+ locals: { data: stats[:cache] }),
14
+ turbo_stream.update("orbit-error-count",
15
+ partial: "rails_orbit/stream/error_count",
16
+ locals: { count: stats[:errors] }),
17
+ ]
18
+ end
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def fetch_all_stats
25
+ sums = Metric.recent(1).group(:key).pluck(:key, Arel.sql("SUM(value)"), Arel.sql("AVG(value)"))
26
+
27
+ by_key_sum = {}
28
+ by_key_avg = {}
29
+ sums.each do |key, total, avg|
30
+ by_key_sum[key] = total.to_f
31
+ by_key_avg[key] = avg.to_f
32
+ end
33
+
34
+ hits = by_key_sum["solid_cache.read_hit"].to_f
35
+ misses = by_key_sum["solid_cache.read_miss"].to_f
36
+ total = hits + misses
37
+
38
+ {
39
+ queue: {
40
+ enqueued: by_key_sum["solid_queue.enqueued"].to_i,
41
+ failed: by_key_sum["solid_queue.failed"].to_i,
42
+ retried: by_key_sum["solid_queue.retried"].to_i,
43
+ avg_ms: by_key_avg["solid_queue.performed_ms"]&.round(1) || 0,
44
+ },
45
+ cache: {
46
+ hits: hits.to_i,
47
+ misses: misses.to_i,
48
+ writes: by_key_sum["solid_cache.write"].to_i,
49
+ hit_rate: total.zero? ? 0.0 : ((hits / total) * 100).round(1),
50
+ },
51
+ errors: by_key_sum["solid_errors.recorded"].to_i,
52
+ }
53
+ end
54
+ end
55
+ end