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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 324f351194399de6bab396eda353654d014715422f8dd0b552d9a2df150240f8
4
+ data.tar.gz: 26243ec5f12b9425e94ba3201685ecc3d554af0b5e617b67927f49362bb6c3b1
5
+ SHA512:
6
+ metadata.gz: 869988c024f9969d5d24019062d54670a2dc4d6b4c0c1e3bc1e8248defabf6423dd23226467ab8bcb7f020398c9f829d85802b77c5435616115d94ffdb1b0271
7
+ data.tar.gz: 20cc91a576016168bbd8f66a2eadee06beb2c3940dd5ee6e30b158b2b52f3f74605c7f444960ce095e27e420ffd36ddb2eaf3f3db6495a41301a99b888fb0499
data/CHANGELOG.md ADDED
@@ -0,0 +1,11 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0] - 2026-03-24
4
+
5
+ - Initial release
6
+ - Multi-adapter storage (SQLite, host DB, external)
7
+ - Instrumentation for solid_queue, solid_cache, solid_errors
8
+ - Hotwire-powered dashboard with Turbo Streams
9
+ - Kamal infrastructure poller (opt-in)
10
+ - Data retention job
11
+ - Install generator
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 dev-ham
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,241 @@
1
+ # rails_orbit
2
+
3
+ [![CI](https://github.com/dev-ham/rails_orbit/actions/workflows/ci.yml/badge.svg)](https://github.com/dev-ham/rails_orbit/actions/workflows/ci.yml)
4
+ [![Gem Version](https://badge.fury.io/rb/rails_orbit.svg)](https://badge.fury.io/rb/rails_orbit)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
6
+
7
+ A mountable Rails engine that gives you an observability dashboard for applications running the Solid stack — `solid_queue`, `solid_cache`, and `solid_errors`. Mount it, and you get real-time metrics without external services.
8
+
9
+ ## Why rails_orbit
10
+
11
+ Rails 8 ships with Solid Queue, Solid Cache, and Solid Errors. They work great, but there is no single place to see how they are performing. rails_orbit fills that gap:
12
+
13
+ - One `/orbit` route gives you jobs, cache, and error metrics in a single dashboard.
14
+ - Zero external dependencies — no Redis, no Datadog, no Prometheus.
15
+ - Non-blocking writes using `concurrent-ruby` so your app stays fast.
16
+ - Works with SQLite, Postgres, MySQL, or any database URL.
17
+
18
+ ## Requirements
19
+
20
+ - Ruby >= 3.1
21
+ - Rails >= 7.1
22
+ - At least one of: `solid_queue`, `solid_cache`, `solid_errors`
23
+
24
+ ## Quick Start
25
+
26
+ ```ruby
27
+ # Gemfile
28
+ gem "rails_orbit"
29
+ ```
30
+
31
+ ```bash
32
+ bundle install
33
+ bin/rails generate rails_orbit:install
34
+ ```
35
+
36
+ The generator creates an initializer, a migration, and mounts the engine route. What happens next depends on your storage adapter:
37
+
38
+ **SQLite (default):** The table is created automatically when the app boots. No migration needed — Orbit uses its own `db/rails_orbit.sqlite3` file, separate from your app's database.
39
+
40
+ **Host database or external:** Run the migration so the table lands in your primary database:
41
+
42
+ ```bash
43
+ bin/rails db:migrate
44
+ ```
45
+
46
+ Set credentials for the dashboard:
47
+
48
+ ```bash
49
+ export ORBIT_USER=admin
50
+ export ORBIT_PASSWORD=secret
51
+ ```
52
+
53
+ Visit `/orbit` in your browser. That is it.
54
+
55
+ ### Useful Commands
56
+
57
+ ```bash
58
+ bin/rails rails_orbit:setup # manually create the metrics table
59
+ bin/rails rails_orbit:status # show config, adapter, table status, metric count
60
+ ```
61
+
62
+ ## Dashboard
63
+
64
+ The dashboard has four pages, all with a dark theme and live updates via Turbo Streams.
65
+
66
+ Every page includes a **date range picker** — choose from 1h, 6h, 24h (default), 7d, or 30d to view historical data.
67
+
68
+ ### Overview
69
+
70
+ The main page shows your application health at a glance:
71
+
72
+ - **Jobs** — enqueued, failed, retried, and discarded counts with hourly trend arrows
73
+ - **Cache** — hit rate with a visual bar, plus hits, misses, and write counts
74
+ - **Errors** — total error count with severity coloring
75
+ - **Interactive charts** — three SVG area charts for Job Duration, Cache Hit Rate, and Error Count. Hover over any point to see the exact value and timestamp.
76
+ - **Live refresh** — a "last updated" indicator shows when data was last fetched
77
+
78
+ Each card has a colored left border that shifts from green to amber to red based on thresholds.
79
+
80
+ Charts use bucketed SQL aggregation (not raw data) so they stay fast even at the 30-day range.
81
+
82
+ ### Jobs
83
+
84
+ A detailed per-queue breakdown:
85
+
86
+ - Summary cards at the top — total enqueued, average duration, failed, discarded
87
+ - A table showing each queue with columns for enqueued, avg duration, failed, retried, and discarded
88
+ - Rows with failures get a subtle red background so they stand out
89
+ - All counts scoped to the selected date range
90
+
91
+ ### Cache
92
+
93
+ Full cache performance visibility:
94
+
95
+ - Total reads split into hits and misses (not just a combined number)
96
+ - A visual hit-rate bar — green fill represents the hit percentage
97
+ - Write, delete, and fetch-hit counts
98
+ - Miss rate shown separately so you can spot cache warming issues
99
+
100
+ ### Errors
101
+
102
+ Exceptions grouped by class for faster triage:
103
+
104
+ - Each exception class shows occurrence count and "last seen" time
105
+ - Up to 5 recent messages displayed per group
106
+ - Resolved status shown if solid_errors supports it
107
+ - Scoped to the selected date range
108
+
109
+ ## Storage Adapters
110
+
111
+ | Adapter | Config | When to Use |
112
+ |---------|--------|-------------|
113
+ | SQLite (default) | `:sqlite` | Local dev, VPS, persistent volumes |
114
+ | Host database | `:host_db` | Heroku, Railway, managed PaaS |
115
+ | External URL | `:external` | PlanetScale, Neon, Turso |
116
+
117
+ Using `:sqlite` on platforms with ephemeral filesystems (Heroku, Railway) will lose data on deploy. The gem detects this and warns you.
118
+
119
+ ```ruby
120
+ RailsOrbit.configure do |config|
121
+ config.storage_adapter = :host_db
122
+ end
123
+ ```
124
+
125
+ For an external database:
126
+
127
+ ```ruby
128
+ RailsOrbit.configure do |config|
129
+ config.storage_adapter = :external
130
+ config.storage_url = ENV["ORBIT_DATABASE_URL"]
131
+ end
132
+ ```
133
+
134
+ ## Configuration
135
+
136
+ ```ruby
137
+ # config/initializers/rails_orbit.rb
138
+ RailsOrbit.configure do |config|
139
+ config.storage_adapter = :sqlite # :sqlite, :host_db, or :external
140
+ config.retention_days = 7 # auto-purge metrics older than this
141
+ config.poll_interval = 5 # seconds between live updates
142
+ config.dashboard_title = "Orbit" # shown in nav and page title
143
+ config.kamal_enabled = false # enable Kamal container stats
144
+ end
145
+ ```
146
+
147
+ ## Authentication
148
+
149
+ The dashboard is protected by a configurable auth block. Three common patterns:
150
+
151
+ ### HTTP Basic (default)
152
+
153
+ Set `ORBIT_USER` and `ORBIT_PASSWORD` environment variables. No code changes needed.
154
+
155
+ ### Devise
156
+
157
+ ```ruby
158
+ config.authenticate_with do |controller|
159
+ controller.authenticate_user!
160
+ controller.head(:forbidden) unless controller.current_user&.admin?
161
+ end
162
+ ```
163
+
164
+ ### Custom
165
+
166
+ ```ruby
167
+ config.authenticate_with do |controller|
168
+ unless controller.session[:orbit_authenticated]
169
+ controller.redirect_to controller.main_app.root_path, alert: "Not authorized"
170
+ end
171
+ end
172
+ ```
173
+
174
+ ## Instrumentation
175
+
176
+ rails_orbit automatically subscribes to these ActiveSupport notifications:
177
+
178
+ | Source | Events Captured |
179
+ |--------|----------------|
180
+ | solid_queue | enqueued, performed (with duration), failed, retried, discarded |
181
+ | solid_cache | read hit, read miss, write, delete, fetch hit |
182
+ | solid_errors | error recorded (with exception class) |
183
+
184
+ All writes happen in a background thread. If the write queue is full, events are discarded rather than blocking your app.
185
+
186
+ ## Data Retention
187
+
188
+ Schedule the built-in retention job to keep your database lean:
189
+
190
+ ```yaml
191
+ # config/recurring.yml (solid_queue)
192
+ rails_orbit_retention:
193
+ class: "RailsOrbit::RetentionJob"
194
+ schedule: "0 2 * * *"
195
+ ```
196
+
197
+ This deletes metrics older than `retention_days` (default: 7).
198
+
199
+ ## Kamal Integration
200
+
201
+ Optional SSH-based container stats polling. Disabled by default.
202
+
203
+ ```ruby
204
+ # Gemfile
205
+ gem "sshkit", "~> 1.21"
206
+ ```
207
+
208
+ ```ruby
209
+ RailsOrbit.configure do |config|
210
+ config.kamal_enabled = true
211
+ config.kamal_ssh_key_path = Rails.root.join(".kamal", "id_ed25519")
212
+ end
213
+ ```
214
+
215
+ Reads `config/deploy.yml` to discover servers. Collects CPU and memory stats from Docker containers every 30 seconds.
216
+
217
+ **Security:**
218
+ - SSH key path must be set explicitly — never auto-discovered
219
+ - Never commit SSH keys to your repository
220
+ - Only enable in production environments
221
+
222
+ ## Contributing
223
+
224
+ ```bash
225
+ git clone https://github.com/dev-ham/rails_orbit.git
226
+ cd rails_orbit
227
+ bundle install
228
+ bundle exec rspec
229
+ ```
230
+
231
+ Tests run against a dummy Rails app in `spec/dummy/`.
232
+
233
+ 1. Fork the repo
234
+ 2. Create a branch (`git checkout -b feature/my-feature`)
235
+ 3. Run tests: `bundle exec rspec`
236
+ 4. Commit and push
237
+ 5. Open a Pull Request
238
+
239
+ ## License
240
+
241
+ MIT License. See [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,232 @@
1
+ (function() {
2
+ "use strict";
3
+
4
+ // ── Poll Controller ───────────────────────────────────────────────────────
5
+
6
+ function OrbitPollController(element) {
7
+ this.element = element;
8
+ this.url = element.dataset.orbitPollUrlValue;
9
+ this.interval = parseInt(element.dataset.orbitPollIntervalValue) || 5000;
10
+ this.timer = null;
11
+ }
12
+
13
+ OrbitPollController.prototype.start = function() {
14
+ if (!this.url) return;
15
+ this.poll();
16
+ this.timer = setInterval(this.poll.bind(this), this.interval);
17
+ };
18
+
19
+ OrbitPollController.prototype.stop = function() {
20
+ if (this.timer) { clearInterval(this.timer); this.timer = null; }
21
+ };
22
+
23
+ OrbitPollController.prototype.poll = function() {
24
+ var self = this;
25
+ fetch(this.url, { headers: { "Accept": "text/vnd.turbo-stream.html" } })
26
+ .then(function(r) { return r.ok ? r.text() : null; })
27
+ .then(function(html) {
28
+ if (!html) return;
29
+ if (typeof Turbo !== "undefined" && Turbo.renderStreamMessage) {
30
+ Turbo.renderStreamMessage(html);
31
+ } else {
32
+ self.fallbackUpdate(html);
33
+ }
34
+ self.refreshTimestamp();
35
+ })
36
+ .catch(function() {});
37
+ };
38
+
39
+ OrbitPollController.prototype.fallbackUpdate = function(html) {
40
+ var doc = new DOMParser().parseFromString(html, "text/html");
41
+ doc.querySelectorAll("turbo-stream").forEach(function(stream) {
42
+ var target = document.getElementById(stream.getAttribute("target"));
43
+ if (!target) return;
44
+ var tpl = stream.querySelector("template");
45
+ if (!tpl) return;
46
+ var action = stream.getAttribute("action");
47
+ if (action === "update" || action === "replace") target.innerHTML = tpl.innerHTML;
48
+ });
49
+ };
50
+
51
+ OrbitPollController.prototype.refreshTimestamp = function() {
52
+ var el = document.getElementById("orbit-last-updated");
53
+ if (!el) return;
54
+ el.dataset.time = new Date().toISOString();
55
+ el.textContent = "Updated just now";
56
+ };
57
+
58
+ // ── Timestamp Ticker ──────────────────────────────────────────────────────
59
+
60
+ function startTimestampTicker() {
61
+ setInterval(function() {
62
+ var el = document.getElementById("orbit-last-updated");
63
+ if (!el || !el.dataset.time) return;
64
+ var s = Math.floor((Date.now() - new Date(el.dataset.time).getTime()) / 1000);
65
+ if (s < 5) el.textContent = "Updated just now";
66
+ else if (s < 60) el.textContent = "Updated " + s + "s ago";
67
+ else el.textContent = "Updated " + Math.floor(s / 60) + "m ago";
68
+ }, 5000);
69
+ }
70
+
71
+ // ── Interactive SVG Chart ─────────────────────────────────────────────────
72
+
73
+ function OrbitChart(container) {
74
+ this.container = container;
75
+ this.data = JSON.parse(container.dataset.chart || "[]");
76
+ this.unit = container.dataset.unit || "";
77
+ this.color = container.dataset.color || "var(--orbit-primary)";
78
+ this.gradId = container.dataset.gradientId || ("orbit-grad-" + Math.random().toString(36).slice(2, 8));
79
+
80
+ if (this.data.length === 0) return;
81
+ this.render();
82
+ }
83
+
84
+ OrbitChart.prototype.render = function() {
85
+ var W = 700, H = 90, PAD_TOP = 5, PAD_BOT = 5;
86
+ var data = this.data;
87
+ var vals = data.map(function(d) { return d.v; });
88
+ var maxV = Math.max.apply(null, vals) || 1;
89
+ var minV = Math.min.apply(null, vals);
90
+ var range = maxV - minV || 1;
91
+ var stepX = data.length > 1 ? W / (data.length - 1) : W;
92
+ var self = this;
93
+
94
+ function yPos(v) {
95
+ return PAD_TOP + ((maxV - v) / range) * (H - PAD_TOP - PAD_BOT);
96
+ }
97
+
98
+ var points = data.map(function(d, i) {
99
+ return { x: (i * stepX).toFixed(1), y: yPos(d.v).toFixed(1) };
100
+ });
101
+
102
+ var polyline = points.map(function(p) { return p.x + "," + p.y; }).join(" ");
103
+ var polygon = "0," + H + " " + polyline + " " + W + "," + H;
104
+
105
+ var svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
106
+ svg.setAttribute("viewBox", "0 0 " + W + " " + (H + 2));
107
+ svg.setAttribute("preserveAspectRatio", "none");
108
+ svg.setAttribute("class", "orbit-chart");
109
+
110
+ svg.innerHTML =
111
+ '<defs><linearGradient id="' + this.gradId + '" x1="0" y1="0" x2="0" y2="1">' +
112
+ '<stop offset="0%" stop-color="' + this.color + '" stop-opacity="0.25"/>' +
113
+ '<stop offset="100%" stop-color="' + this.color + '" stop-opacity="0.02"/>' +
114
+ '</linearGradient></defs>' +
115
+ '<polygon points="' + polygon + '" fill="url(#' + this.gradId + ')"/>' +
116
+ '<polyline points="' + polyline + '" fill="none" stroke="' + this.color + '" stroke-width="1.5" stroke-linejoin="round"/>';
117
+
118
+ var hitZones = document.createElementNS("http://www.w3.org/2000/svg", "g");
119
+ hitZones.setAttribute("class", "orbit-chart__hitzone");
120
+
121
+ for (var i = 0; i < points.length; i++) {
122
+ var rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
123
+ var x0 = i === 0 ? 0 : parseFloat(points[i].x) - stepX / 2;
124
+ rect.setAttribute("x", x0);
125
+ rect.setAttribute("y", 0);
126
+ rect.setAttribute("width", stepX);
127
+ rect.setAttribute("height", H);
128
+ rect.setAttribute("fill", "transparent");
129
+ rect.setAttribute("data-idx", i);
130
+ hitZones.appendChild(rect);
131
+ }
132
+ svg.appendChild(hitZones);
133
+
134
+ var dot = document.createElementNS("http://www.w3.org/2000/svg", "circle");
135
+ dot.setAttribute("r", "3");
136
+ dot.setAttribute("fill", this.color);
137
+ dot.setAttribute("class", "orbit-chart__dot");
138
+ dot.style.display = "none";
139
+ svg.appendChild(dot);
140
+
141
+ var vLine = document.createElementNS("http://www.w3.org/2000/svg", "line");
142
+ vLine.setAttribute("y1", "0");
143
+ vLine.setAttribute("y2", H);
144
+ vLine.setAttribute("stroke", "var(--orbit-border-hover)");
145
+ vLine.setAttribute("stroke-width", "1");
146
+ vLine.setAttribute("stroke-dasharray", "2,2");
147
+ vLine.setAttribute("class", "orbit-chart__vline");
148
+ vLine.style.display = "none";
149
+ svg.appendChild(vLine);
150
+
151
+ var tooltip = document.createElement("div");
152
+ tooltip.className = "orbit-chart__tooltip";
153
+ tooltip.style.display = "none";
154
+
155
+ var wrap = document.createElement("div");
156
+ wrap.className = "orbit-chart-wrap";
157
+ wrap.appendChild(svg);
158
+ wrap.appendChild(tooltip);
159
+
160
+ var labels = document.createElement("div");
161
+ labels.className = "orbit-chart__labels";
162
+ labels.innerHTML =
163
+ '<span class="orbit-chart__label">' + minV.toFixed(1) + this.unit + '</span>' +
164
+ '<span class="orbit-chart__label orbit-chart__label--current">' +
165
+ data[data.length - 1].v.toFixed(1) + this.unit + ' now</span>' +
166
+ '<span class="orbit-chart__label">' + maxV.toFixed(1) + this.unit + ' peak</span>';
167
+ wrap.appendChild(labels);
168
+
169
+ this.container.innerHTML = "";
170
+ this.container.appendChild(wrap);
171
+
172
+ svg.addEventListener("mousemove", function(e) {
173
+ var rect = svg.getBoundingClientRect();
174
+ var mx = (e.clientX - rect.left) / rect.width * W;
175
+ var idx = Math.round(mx / stepX);
176
+ if (idx < 0) idx = 0;
177
+ if (idx >= data.length) idx = data.length - 1;
178
+
179
+ var pt = points[idx];
180
+ dot.setAttribute("cx", pt.x);
181
+ dot.setAttribute("cy", pt.y);
182
+ dot.style.display = "";
183
+
184
+ vLine.setAttribute("x1", pt.x);
185
+ vLine.setAttribute("x2", pt.x);
186
+ vLine.style.display = "";
187
+
188
+ var d = data[idx];
189
+ tooltip.textContent = self.formatTime(d.t) + " " + d.v.toFixed(1) + self.unit;
190
+ tooltip.style.display = "";
191
+
192
+ var pctX = parseFloat(pt.x) / W * 100;
193
+ tooltip.style.left = pctX + "%";
194
+ tooltip.style.transform = pctX > 80 ? "translateX(-100%)" : (pctX < 20 ? "translateX(0)" : "translateX(-50%)");
195
+ });
196
+
197
+ svg.addEventListener("mouseleave", function() {
198
+ dot.style.display = "none";
199
+ vLine.style.display = "none";
200
+ tooltip.style.display = "none";
201
+ });
202
+ };
203
+
204
+ OrbitChart.prototype.formatTime = function(ts) {
205
+ if (!ts) return "";
206
+ var d = new Date(ts.replace(" ", "T") + (ts.indexOf("Z") === -1 ? "Z" : ""));
207
+ if (isNaN(d.getTime())) return ts;
208
+ var h = d.getHours().toString().padStart(2, "0");
209
+ var m = d.getMinutes().toString().padStart(2, "0");
210
+ var mon = d.toLocaleString("en", { month: "short" });
211
+ var day = d.getDate();
212
+ return mon + " " + day + " " + h + ":" + m;
213
+ };
214
+
215
+ // ── Init ──────────────────────────────────────────────────────────────────
216
+
217
+ function initAll() {
218
+ document.querySelectorAll("[data-controller='orbit-poll']").forEach(function(el) {
219
+ new OrbitPollController(el).start();
220
+ });
221
+ document.querySelectorAll(".orbit-chart-interactive").forEach(function(el) {
222
+ new OrbitChart(el);
223
+ });
224
+ startTimestampTicker();
225
+ }
226
+
227
+ if (document.readyState === "loading") {
228
+ document.addEventListener("DOMContentLoaded", initAll);
229
+ } else {
230
+ initAll();
231
+ }
232
+ })();