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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +11 -0
- data/LICENSE.txt +21 -0
- data/README.md +241 -0
- data/app/assets/javascripts/rails_orbit/application.js +232 -0
- data/app/assets/stylesheets/rails_orbit/application.css +536 -0
- data/app/controllers/rails_orbit/application_controller.rb +26 -0
- data/app/controllers/rails_orbit/dashboard_controller.rb +84 -0
- data/app/controllers/rails_orbit/stream_controller.rb +55 -0
- data/app/helpers/rails_orbit/dashboard_helper.rb +44 -0
- data/app/helpers/rails_orbit/icon_helper.rb +19 -0
- data/app/jobs/rails_orbit/application_job.rb +4 -0
- data/app/jobs/rails_orbit/retention_job.rb +22 -0
- data/app/models/rails_orbit/application_record.rb +6 -0
- data/app/models/rails_orbit/metric.rb +97 -0
- data/app/views/layouts/rails_orbit/application.html.erb +20 -0
- data/app/views/rails_orbit/dashboard/_overview_card.html.erb +17 -0
- data/app/views/rails_orbit/dashboard/cache.html.erb +58 -0
- data/app/views/rails_orbit/dashboard/errors.html.erb +54 -0
- data/app/views/rails_orbit/dashboard/jobs.html.erb +64 -0
- data/app/views/rails_orbit/dashboard/overview.html.erb +67 -0
- data/app/views/rails_orbit/shared/_delta.html.erb +14 -0
- data/app/views/rails_orbit/shared/_nav.html.erb +26 -0
- data/app/views/rails_orbit/shared/_range_picker.html.erb +5 -0
- data/app/views/rails_orbit/stream/_cache_stats.html.erb +9 -0
- data/app/views/rails_orbit/stream/_error_count.html.erb +1 -0
- data/app/views/rails_orbit/stream/_queue_stats.html.erb +8 -0
- data/app/views/rails_orbit/stream/index.turbo_stream.erb +11 -0
- data/config/routes.rb +9 -0
- data/lib/generators/rails_orbit/install_generator.rb +55 -0
- data/lib/generators/rails_orbit/templates/create_orbit_metrics.rb.erb +14 -0
- data/lib/generators/rails_orbit/templates/initializer.rb +31 -0
- data/lib/rails_orbit/configuration.rb +73 -0
- data/lib/rails_orbit/database_setup.rb +87 -0
- data/lib/rails_orbit/engine.rb +80 -0
- data/lib/rails_orbit/instrumentation.rb +83 -0
- data/lib/rails_orbit/kamal/config_reader.rb +32 -0
- data/lib/rails_orbit/kamal/poller.rb +64 -0
- data/lib/rails_orbit/kamal/stats_collector.rb +42 -0
- data/lib/rails_orbit/metric_writer.rb +37 -0
- data/lib/rails_orbit/time_range.rb +39 -0
- data/lib/rails_orbit/version.rb +3 -0
- data/lib/rails_orbit.rb +24 -0
- data/lib/tasks/rails_orbit.rake +60 -0
- data/public/assets/rails_orbit/application.css +536 -0
- data/public/assets/rails_orbit/application.js +237 -0
- 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
|
+
[](https://github.com/dev-ham/rails_orbit/actions/workflows/ci.yml)
|
|
4
|
+
[](https://badge.fury.io/rb/rails_orbit)
|
|
5
|
+
[](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
|
+
})();
|