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,237 @@
1
+ (() => {
2
+ "use strict";
3
+
4
+ // ── Poll Controller ───────────────────────────────────────────────────────
5
+
6
+ class OrbitPollController {
7
+ constructor(element) {
8
+ this.element = element;
9
+ this.url = element.dataset.orbitPollUrlValue;
10
+ this.interval = parseInt(element.dataset.orbitPollIntervalValue, 10) || 5000;
11
+ this.timer = null;
12
+ }
13
+
14
+ start() {
15
+ if (!this.url) return;
16
+ this.poll();
17
+ this.timer = setInterval(() => this.poll(), this.interval);
18
+ }
19
+
20
+ stop() {
21
+ if (this.timer) { clearInterval(this.timer); this.timer = null; }
22
+ }
23
+
24
+ async poll() {
25
+ try {
26
+ const response = await fetch(this.url, {
27
+ headers: { "Accept": "text/vnd.turbo-stream.html" }
28
+ });
29
+ if (!response.ok) return;
30
+
31
+ const html = await response.text();
32
+ if (!html) return;
33
+
34
+ if (typeof Turbo !== "undefined" && Turbo.renderStreamMessage) {
35
+ Turbo.renderStreamMessage(html);
36
+ } else {
37
+ this.fallbackUpdate(html);
38
+ }
39
+ this.refreshTimestamp();
40
+ } catch (err) {
41
+ // Silently swallow network errors — will retry on next interval
42
+ }
43
+ }
44
+
45
+ fallbackUpdate(html) {
46
+ const doc = new DOMParser().parseFromString(html, "text/html");
47
+ for (const stream of doc.querySelectorAll("turbo-stream")) {
48
+ const target = document.getElementById(stream.getAttribute("target"));
49
+ if (!target) continue;
50
+ const tpl = stream.querySelector("template");
51
+ if (!tpl) continue;
52
+ const action = stream.getAttribute("action");
53
+ if (action === "update" || action === "replace") {
54
+ target.innerHTML = tpl.innerHTML;
55
+ }
56
+ }
57
+ }
58
+
59
+ refreshTimestamp() {
60
+ const el = document.getElementById("orbit-last-updated");
61
+ if (!el) return;
62
+ el.dataset.time = new Date().toISOString();
63
+ el.textContent = "Updated just now";
64
+ }
65
+ }
66
+
67
+ // ── Timestamp Ticker ──────────────────────────────────────────────────────
68
+
69
+ function startTimestampTicker() {
70
+ setInterval(() => {
71
+ const el = document.getElementById("orbit-last-updated");
72
+ if (!el || !el.dataset.time) return;
73
+ const s = Math.floor((Date.now() - new Date(el.dataset.time).getTime()) / 1000);
74
+ if (s < 5) el.textContent = "Updated just now";
75
+ else if (s < 60) el.textContent = `Updated ${s}s ago`;
76
+ else el.textContent = `Updated ${Math.floor(s / 60)}m ago`;
77
+ }, 5000);
78
+ }
79
+
80
+ // ── Interactive SVG Chart ─────────────────────────────────────────────────
81
+
82
+ class OrbitChart {
83
+ constructor(container) {
84
+ this.container = container;
85
+ this.data = JSON.parse(container.dataset.chart || "[]");
86
+ this.unit = container.dataset.unit || "";
87
+ this.color = container.dataset.color || "var(--orbit-primary)";
88
+ this.gradId = container.dataset.gradientId || `orbit-grad-${Math.random().toString(36).slice(2, 8)}`;
89
+
90
+ if (this.data.length > 0) this.render();
91
+ }
92
+
93
+ render() {
94
+ const W = 700, H = 90, PAD_TOP = 5, PAD_BOT = 5;
95
+ const { data, unit, color, gradId } = this;
96
+ const vals = data.map(d => d.v);
97
+ const maxV = Math.max(...vals) || 1;
98
+ const minV = Math.min(...vals);
99
+ const range = maxV - minV || 1;
100
+ const stepX = data.length > 1 ? W / (data.length - 1) : W;
101
+
102
+ const yPos = v => PAD_TOP + ((maxV - v) / range) * (H - PAD_TOP - PAD_BOT);
103
+
104
+ const points = data.map((d, i) => ({
105
+ x: (i * stepX).toFixed(1),
106
+ y: yPos(d.v).toFixed(1)
107
+ }));
108
+
109
+ const polyline = points.map(p => `${p.x},${p.y}`).join(" ");
110
+ const polygon = `0,${H} ${polyline} ${W},${H}`;
111
+
112
+ const ns = "http://www.w3.org/2000/svg";
113
+ const svg = document.createElementNS(ns, "svg");
114
+ svg.setAttribute("viewBox", `0 0 ${W} ${H + 2}`);
115
+ svg.setAttribute("preserveAspectRatio", "none");
116
+ svg.setAttribute("class", "orbit-chart");
117
+
118
+ svg.innerHTML =
119
+ `<defs><linearGradient id="${gradId}" x1="0" y1="0" x2="0" y2="1">` +
120
+ `<stop offset="0%" stop-color="${color}" stop-opacity="0.25"/>` +
121
+ `<stop offset="100%" stop-color="${color}" stop-opacity="0.02"/>` +
122
+ `</linearGradient></defs>` +
123
+ `<polygon points="${polygon}" fill="url(#${gradId})"/>` +
124
+ `<polyline points="${polyline}" fill="none" stroke="${color}" stroke-width="1.5" stroke-linejoin="round"/>`;
125
+
126
+ const hitZones = document.createElementNS(ns, "g");
127
+ hitZones.setAttribute("class", "orbit-chart__hitzone");
128
+
129
+ for (let i = 0; i < points.length; i++) {
130
+ const rect = document.createElementNS(ns, "rect");
131
+ const x0 = i === 0 ? 0 : parseFloat(points[i].x) - stepX / 2;
132
+ rect.setAttribute("x", x0);
133
+ rect.setAttribute("y", 0);
134
+ rect.setAttribute("width", stepX);
135
+ rect.setAttribute("height", H);
136
+ rect.setAttribute("fill", "transparent");
137
+ rect.setAttribute("data-idx", i);
138
+ hitZones.appendChild(rect);
139
+ }
140
+ svg.appendChild(hitZones);
141
+
142
+ const dot = document.createElementNS(ns, "circle");
143
+ dot.setAttribute("r", "3");
144
+ dot.setAttribute("fill", color);
145
+ dot.setAttribute("class", "orbit-chart__dot");
146
+ dot.style.display = "none";
147
+ svg.appendChild(dot);
148
+
149
+ const vLine = document.createElementNS(ns, "line");
150
+ vLine.setAttribute("y1", "0");
151
+ vLine.setAttribute("y2", H);
152
+ vLine.setAttribute("stroke", "var(--orbit-border-hover)");
153
+ vLine.setAttribute("stroke-width", "1");
154
+ vLine.setAttribute("stroke-dasharray", "2,2");
155
+ vLine.setAttribute("class", "orbit-chart__vline");
156
+ vLine.style.display = "none";
157
+ svg.appendChild(vLine);
158
+
159
+ const tooltip = document.createElement("div");
160
+ tooltip.className = "orbit-chart__tooltip";
161
+ tooltip.style.display = "none";
162
+
163
+ const wrap = document.createElement("div");
164
+ wrap.className = "orbit-chart-wrap";
165
+ wrap.appendChild(svg);
166
+ wrap.appendChild(tooltip);
167
+
168
+ const labels = document.createElement("div");
169
+ labels.className = "orbit-chart__labels";
170
+ labels.innerHTML =
171
+ `<span class="orbit-chart__label">${minV.toFixed(1)}${unit}</span>` +
172
+ `<span class="orbit-chart__label orbit-chart__label--current">${data[data.length - 1].v.toFixed(1)}${unit} now</span>` +
173
+ `<span class="orbit-chart__label">${maxV.toFixed(1)}${unit} peak</span>`;
174
+ wrap.appendChild(labels);
175
+
176
+ this.container.innerHTML = "";
177
+ this.container.appendChild(wrap);
178
+
179
+ svg.addEventListener("mousemove", e => {
180
+ const rect = svg.getBoundingClientRect();
181
+ const mx = (e.clientX - rect.left) / rect.width * W;
182
+ let idx = Math.round(mx / stepX);
183
+ idx = Math.max(0, Math.min(idx, data.length - 1));
184
+
185
+ const pt = points[idx];
186
+ dot.setAttribute("cx", pt.x);
187
+ dot.setAttribute("cy", pt.y);
188
+ dot.style.display = "";
189
+
190
+ vLine.setAttribute("x1", pt.x);
191
+ vLine.setAttribute("x2", pt.x);
192
+ vLine.style.display = "";
193
+
194
+ tooltip.textContent = `${this.formatTime(data[idx].t)} ${data[idx].v.toFixed(1)}${unit}`;
195
+ tooltip.style.display = "";
196
+
197
+ const pctX = parseFloat(pt.x) / W * 100;
198
+ tooltip.style.left = `${pctX}%`;
199
+ tooltip.style.transform = pctX > 80 ? "translateX(-100%)" : (pctX < 20 ? "translateX(0)" : "translateX(-50%)");
200
+ });
201
+
202
+ svg.addEventListener("mouseleave", () => {
203
+ dot.style.display = "none";
204
+ vLine.style.display = "none";
205
+ tooltip.style.display = "none";
206
+ });
207
+ }
208
+
209
+ formatTime(ts) {
210
+ if (!ts) return "";
211
+ const d = new Date(ts.replace(" ", "T") + (ts.includes("Z") ? "" : "Z"));
212
+ if (isNaN(d.getTime())) return ts;
213
+ const h = d.getHours().toString().padStart(2, "0");
214
+ const m = d.getMinutes().toString().padStart(2, "0");
215
+ const mon = d.toLocaleString("en", { month: "short" });
216
+ return `${mon} ${d.getDate()} ${h}:${m}`;
217
+ }
218
+ }
219
+
220
+ // ── Init ──────────────────────────────────────────────────────────────────
221
+
222
+ function initAll() {
223
+ for (const el of document.querySelectorAll("[data-controller='orbit-poll']")) {
224
+ new OrbitPollController(el).start();
225
+ }
226
+ for (const el of document.querySelectorAll(".orbit-chart-interactive")) {
227
+ new OrbitChart(el);
228
+ }
229
+ startTimestampTicker();
230
+ }
231
+
232
+ if (document.readyState === "loading") {
233
+ document.addEventListener("DOMContentLoaded", initAll);
234
+ } else {
235
+ initAll();
236
+ }
237
+ })();
metadata ADDED
@@ -0,0 +1,264 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rails_orbit
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - dev-ham
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.1'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '9'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '7.1'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '9'
32
+ - !ruby/object:Gem::Dependency
33
+ name: concurrent-ruby
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - "~>"
37
+ - !ruby/object:Gem::Version
38
+ version: '1.2'
39
+ type: :runtime
40
+ prerelease: false
41
+ version_requirements: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - "~>"
44
+ - !ruby/object:Gem::Version
45
+ version: '1.2'
46
+ - !ruby/object:Gem::Dependency
47
+ name: turbo-rails
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: '1.5'
53
+ type: :runtime
54
+ prerelease: false
55
+ version_requirements: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '1.5'
60
+ - !ruby/object:Gem::Dependency
61
+ name: rspec-rails
62
+ requirement: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - "~>"
65
+ - !ruby/object:Gem::Version
66
+ version: '6.0'
67
+ type: :development
68
+ prerelease: false
69
+ version_requirements: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - "~>"
72
+ - !ruby/object:Gem::Version
73
+ version: '6.0'
74
+ - !ruby/object:Gem::Dependency
75
+ name: factory_bot_rails
76
+ requirement: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: '0'
81
+ type: :development
82
+ prerelease: false
83
+ version_requirements: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: '0'
88
+ - !ruby/object:Gem::Dependency
89
+ name: capybara
90
+ requirement: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - ">="
93
+ - !ruby/object:Gem::Version
94
+ version: '0'
95
+ type: :development
96
+ prerelease: false
97
+ version_requirements: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - ">="
100
+ - !ruby/object:Gem::Version
101
+ version: '0'
102
+ - !ruby/object:Gem::Dependency
103
+ name: selenium-webdriver
104
+ requirement: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - ">="
107
+ - !ruby/object:Gem::Version
108
+ version: '0'
109
+ type: :development
110
+ prerelease: false
111
+ version_requirements: !ruby/object:Gem::Requirement
112
+ requirements:
113
+ - - ">="
114
+ - !ruby/object:Gem::Version
115
+ version: '0'
116
+ - !ruby/object:Gem::Dependency
117
+ name: solid_queue
118
+ requirement: !ruby/object:Gem::Requirement
119
+ requirements:
120
+ - - ">="
121
+ - !ruby/object:Gem::Version
122
+ version: '0'
123
+ type: :development
124
+ prerelease: false
125
+ version_requirements: !ruby/object:Gem::Requirement
126
+ requirements:
127
+ - - ">="
128
+ - !ruby/object:Gem::Version
129
+ version: '0'
130
+ - !ruby/object:Gem::Dependency
131
+ name: solid_cache
132
+ requirement: !ruby/object:Gem::Requirement
133
+ requirements:
134
+ - - ">="
135
+ - !ruby/object:Gem::Version
136
+ version: '0'
137
+ type: :development
138
+ prerelease: false
139
+ version_requirements: !ruby/object:Gem::Requirement
140
+ requirements:
141
+ - - ">="
142
+ - !ruby/object:Gem::Version
143
+ version: '0'
144
+ - !ruby/object:Gem::Dependency
145
+ name: solid_errors
146
+ requirement: !ruby/object:Gem::Requirement
147
+ requirements:
148
+ - - ">="
149
+ - !ruby/object:Gem::Version
150
+ version: '0'
151
+ type: :development
152
+ prerelease: false
153
+ version_requirements: !ruby/object:Gem::Requirement
154
+ requirements:
155
+ - - ">="
156
+ - !ruby/object:Gem::Version
157
+ version: '0'
158
+ - !ruby/object:Gem::Dependency
159
+ name: sqlite3
160
+ requirement: !ruby/object:Gem::Requirement
161
+ requirements:
162
+ - - "~>"
163
+ - !ruby/object:Gem::Version
164
+ version: '2.0'
165
+ type: :development
166
+ prerelease: false
167
+ version_requirements: !ruby/object:Gem::Requirement
168
+ requirements:
169
+ - - "~>"
170
+ - !ruby/object:Gem::Version
171
+ version: '2.0'
172
+ - !ruby/object:Gem::Dependency
173
+ name: pg
174
+ requirement: !ruby/object:Gem::Requirement
175
+ requirements:
176
+ - - ">="
177
+ - !ruby/object:Gem::Version
178
+ version: '0'
179
+ type: :development
180
+ prerelease: false
181
+ version_requirements: !ruby/object:Gem::Requirement
182
+ requirements:
183
+ - - ">="
184
+ - !ruby/object:Gem::Version
185
+ version: '0'
186
+ description: A mountable Rails engine providing real-time metrics, job monitoring,
187
+ cache analytics, and error tracking for applications built on the Solid trifecta.
188
+ email:
189
+ - devhammad.masood@gmail.com
190
+ executables: []
191
+ extensions: []
192
+ extra_rdoc_files: []
193
+ files:
194
+ - CHANGELOG.md
195
+ - LICENSE.txt
196
+ - README.md
197
+ - app/assets/javascripts/rails_orbit/application.js
198
+ - app/assets/stylesheets/rails_orbit/application.css
199
+ - app/controllers/rails_orbit/application_controller.rb
200
+ - app/controllers/rails_orbit/dashboard_controller.rb
201
+ - app/controllers/rails_orbit/stream_controller.rb
202
+ - app/helpers/rails_orbit/dashboard_helper.rb
203
+ - app/helpers/rails_orbit/icon_helper.rb
204
+ - app/jobs/rails_orbit/application_job.rb
205
+ - app/jobs/rails_orbit/retention_job.rb
206
+ - app/models/rails_orbit/application_record.rb
207
+ - app/models/rails_orbit/metric.rb
208
+ - app/views/layouts/rails_orbit/application.html.erb
209
+ - app/views/rails_orbit/dashboard/_overview_card.html.erb
210
+ - app/views/rails_orbit/dashboard/cache.html.erb
211
+ - app/views/rails_orbit/dashboard/errors.html.erb
212
+ - app/views/rails_orbit/dashboard/jobs.html.erb
213
+ - app/views/rails_orbit/dashboard/overview.html.erb
214
+ - app/views/rails_orbit/shared/_delta.html.erb
215
+ - app/views/rails_orbit/shared/_nav.html.erb
216
+ - app/views/rails_orbit/shared/_range_picker.html.erb
217
+ - app/views/rails_orbit/stream/_cache_stats.html.erb
218
+ - app/views/rails_orbit/stream/_error_count.html.erb
219
+ - app/views/rails_orbit/stream/_queue_stats.html.erb
220
+ - app/views/rails_orbit/stream/index.turbo_stream.erb
221
+ - config/routes.rb
222
+ - lib/generators/rails_orbit/install_generator.rb
223
+ - lib/generators/rails_orbit/templates/create_orbit_metrics.rb.erb
224
+ - lib/generators/rails_orbit/templates/initializer.rb
225
+ - lib/rails_orbit.rb
226
+ - lib/rails_orbit/configuration.rb
227
+ - lib/rails_orbit/database_setup.rb
228
+ - lib/rails_orbit/engine.rb
229
+ - lib/rails_orbit/instrumentation.rb
230
+ - lib/rails_orbit/kamal/config_reader.rb
231
+ - lib/rails_orbit/kamal/poller.rb
232
+ - lib/rails_orbit/kamal/stats_collector.rb
233
+ - lib/rails_orbit/metric_writer.rb
234
+ - lib/rails_orbit/time_range.rb
235
+ - lib/rails_orbit/version.rb
236
+ - lib/tasks/rails_orbit.rake
237
+ - public/assets/rails_orbit/application.css
238
+ - public/assets/rails_orbit/application.js
239
+ homepage: https://github.com/dev-ham/rails_orbit
240
+ licenses:
241
+ - MIT
242
+ metadata:
243
+ source_code_uri: https://github.com/dev-ham/rails_orbit
244
+ changelog_uri: https://github.com/dev-ham/rails_orbit/blob/main/CHANGELOG.md
245
+ bug_tracker_uri: https://github.com/dev-ham/rails_orbit/issues
246
+ rubygems_mfa_required: 'true'
247
+ rdoc_options: []
248
+ require_paths:
249
+ - lib
250
+ required_ruby_version: !ruby/object:Gem::Requirement
251
+ requirements:
252
+ - - ">="
253
+ - !ruby/object:Gem::Version
254
+ version: 3.1.0
255
+ required_rubygems_version: !ruby/object:Gem::Requirement
256
+ requirements:
257
+ - - ">="
258
+ - !ruby/object:Gem::Version
259
+ version: '0'
260
+ requirements: []
261
+ rubygems_version: 4.0.3
262
+ specification_version: 4
263
+ summary: Observability dashboard for solid_queue, solid_cache, and solid_errors
264
+ test_files: []