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,44 @@
1
+ module RailsOrbit
2
+ module DashboardHelper
3
+ def range_options
4
+ TimeRange::OPTIONS
5
+ end
6
+
7
+ def range_label
8
+ @time_range.label
9
+ end
10
+
11
+ def severity_class(count, thresholds: { success: 0, warning: 10 })
12
+ if count <= thresholds[:success]
13
+ "success"
14
+ elsif count < thresholds[:warning]
15
+ "warning"
16
+ else
17
+ "danger"
18
+ end
19
+ end
20
+
21
+ def cache_severity(hit_rate)
22
+ if hit_rate >= 80
23
+ "success"
24
+ elsif hit_rate >= 50
25
+ "warning"
26
+ else
27
+ "danger"
28
+ end
29
+ end
30
+
31
+ def delta_title(range_key)
32
+ window = TimeRange::OPTIONS.dig(range_key, :delta_window)
33
+ return "vs previous period" unless window
34
+ label = case window
35
+ when 15.minutes then "15 min"
36
+ when 1.hour then "hour"
37
+ when 6.hours then "6 hours"
38
+ when 1.day then "day"
39
+ else "period"
40
+ end
41
+ "vs previous #{label}"
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,19 @@
1
+ module RailsOrbit
2
+ module IconHelper
3
+ ICONS = {
4
+ overview: '<svg class="orbit-nav__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>',
5
+ jobs: '<svg class="%{css_class}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8"/><path d="M12 17v4"/></svg>',
6
+ cache: '<svg class="%{css_class}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14a9 3 0 0 0 18 0V5"/><path d="M3 12a9 3 0 0 0 18 0"/></svg>',
7
+ errors: '<svg class="%{css_class}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
8
+ orbit: '<svg class="orbit-nav__logo" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><circle cx="12" cy="12" r="8" stroke-dasharray="4 3"/></svg>',
9
+ delta_up: '<svg class="orbit-delta__icon" viewBox="0 0 12 12" fill="currentColor"><path d="M6 2L10 7H2z"/></svg>',
10
+ delta_down:'<svg class="orbit-delta__icon" viewBox="0 0 12 12" fill="currentColor"><path d="M6 10L2 5h8z"/></svg>',
11
+ }.freeze
12
+
13
+ def orbit_icon(name, css_class: "orbit-icon")
14
+ template = ICONS[name.to_sym]
15
+ return "" unless template
16
+ (template % { css_class: css_class }).html_safe
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,4 @@
1
+ module RailsOrbit
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,22 @@
1
+ module RailsOrbit
2
+ class RetentionJob < ApplicationJob
3
+ queue_as :default
4
+
5
+ BATCH_SIZE = 1_000
6
+
7
+ def perform
8
+ days = RailsOrbit.configuration.retention_days
9
+ total = 0
10
+
11
+ loop do
12
+ ids = Metric.older_than(days).limit(BATCH_SIZE).pluck(:id)
13
+ break if ids.empty?
14
+ deleted = Metric.where(id: ids).delete_all
15
+ total += deleted
16
+ break if ids.size < BATCH_SIZE
17
+ end
18
+
19
+ Rails.logger.info "[rails_orbit] RetentionJob: purged #{total} metrics older than #{days} days."
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,6 @@
1
+ module RailsOrbit
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ self.table_name_prefix = "rails_orbit_"
5
+ end
6
+ end
@@ -0,0 +1,97 @@
1
+ module RailsOrbit
2
+ class Metric < ApplicationRecord
3
+ validates :key, presence: true
4
+ validates :value, presence: true, numericality: true
5
+ validates :recorded_at, presence: true
6
+
7
+ scope :recent, ->(days = 1) { where(recorded_at: days.days.ago..) }
8
+ scope :since, ->(time) { where(recorded_at: time..) }
9
+ scope :for_key, ->(k) { where(key: k) }
10
+ scope :older_than, ->(days) { where(recorded_at: ..days.days.ago) }
11
+
12
+ def self.record(key:, value:, dimension: nil, at: Time.current)
13
+ create!(key: key, value: value, dimension: dimension, recorded_at: at)
14
+ end
15
+
16
+ def self.hit_rate(hits, misses)
17
+ total = hits.to_f + misses.to_f
18
+ total.zero? ? 0.0 : ((hits / total) * 100).round(1)
19
+ end
20
+
21
+ def self.sums_since(time)
22
+ since(time).group(:key).sum(:value)
23
+ end
24
+
25
+ def self.compute_delta(key, window:)
26
+ now = Time.current
27
+ current = where(recorded_at: (now - window)..now).for_key(key).sum(:value).to_i
28
+ previous = where(recorded_at: (now - window * 2)..(now - window)).for_key(key).sum(:value).to_i
29
+ return { value: 0, direction: :flat } if previous.zero? && current.zero?
30
+ return { value: 100, direction: :up } if previous.zero?
31
+
32
+ pct = (((current - previous).to_f / previous) * 100).round(0)
33
+ direction = pct.positive? ? :up : (pct.negative? ? :down : :flat)
34
+ { value: pct.abs, direction: direction }
35
+ end
36
+
37
+ def self.compute_hit_rate_delta(window:)
38
+ now = Time.current
39
+ cur_hits = where(recorded_at: (now - window)..now).for_key("solid_cache.read_hit").sum(:value).to_f
40
+ cur_misses = where(recorded_at: (now - window)..now).for_key("solid_cache.read_miss").sum(:value).to_f
41
+ prev_hits = where(recorded_at: (now - window * 2)..(now - window)).for_key("solid_cache.read_hit").sum(:value).to_f
42
+ prev_misses = where(recorded_at: (now - window * 2)..(now - window)).for_key("solid_cache.read_miss").sum(:value).to_f
43
+
44
+ cur_rate = hit_rate(cur_hits, cur_misses)
45
+ prev_rate = hit_rate(prev_hits, prev_misses)
46
+ diff = (cur_rate - prev_rate).round(1)
47
+ direction = diff.positive? ? :up : (diff.negative? ? :down : :flat)
48
+ { value: diff.abs, direction: direction }
49
+ end
50
+
51
+ def self.bucketed_series(key, since:, bucket_minutes:, aggregate: :sum)
52
+ conn = connection
53
+ seconds = bucket_minutes * 60
54
+ agg_fn = aggregate == :avg ? "AVG" : "SUM"
55
+ bucket = bucket_expression(seconds)
56
+
57
+ sql = "SELECT #{bucket} AS bucket, #{agg_fn}(value) AS val " \
58
+ "FROM #{conn.quote_table_name(table_name)} " \
59
+ "WHERE key = #{conn.quote(key)} AND recorded_at >= #{conn.quote(since.utc.strftime('%Y-%m-%d %H:%M:%S'))} " \
60
+ "GROUP BY bucket ORDER BY bucket"
61
+
62
+ conn.select_all(sql).rows.map { |row| { t: row[0].to_s, v: row[1].to_f.round(1) } }
63
+ end
64
+
65
+ def self.bucketed_hit_rate_series(since:, bucket_minutes:)
66
+ conn = connection
67
+ seconds = bucket_minutes * 60
68
+ bucket = bucket_expression(seconds)
69
+ q_since = conn.quote(since.utc.strftime("%Y-%m-%d %H:%M:%S"))
70
+ q_hit = conn.quote("solid_cache.read_hit")
71
+ q_miss = conn.quote("solid_cache.read_miss")
72
+
73
+ sql = "SELECT #{bucket} AS bucket, " \
74
+ "SUM(CASE WHEN key = #{q_hit} THEN value ELSE 0 END) AS hits, " \
75
+ "SUM(CASE WHEN key = #{q_miss} THEN value ELSE 0 END) AS misses " \
76
+ "FROM #{conn.quote_table_name(table_name)} " \
77
+ "WHERE key IN (#{q_hit}, #{q_miss}) AND recorded_at >= #{q_since} " \
78
+ "GROUP BY bucket ORDER BY bucket"
79
+
80
+ conn.select_all(sql).rows.map do |row|
81
+ { t: row[0].to_s, v: hit_rate(row[1].to_f, row[2].to_f) }
82
+ end
83
+ end
84
+
85
+ def self.bucket_expression(seconds)
86
+ adapter = connection.adapter_name.downcase
87
+ if adapter.include?("sqlite")
88
+ "datetime((strftime('%s', recorded_at) / #{seconds}) * #{seconds}, 'unixepoch')"
89
+ elsif adapter.include?("postgres")
90
+ "to_timestamp(floor(extract(epoch from recorded_at) / #{seconds}) * #{seconds})"
91
+ else
92
+ "FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(recorded_at) / #{seconds}) * #{seconds})"
93
+ end
94
+ end
95
+ private_class_method :bucket_expression
96
+ end
97
+ end
@@ -0,0 +1,20 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title><%= RailsOrbit.configuration.dashboard_title %></title>
7
+ <%= stylesheet_link_tag "/assets/rails_orbit/application.css", media: "all" %>
8
+ <%= javascript_include_tag "/assets/rails_orbit/application.js", defer: true %>
9
+ <%= csrf_meta_tags %>
10
+ </head>
11
+ <body class="orbit-body">
12
+ <%= render "rails_orbit/shared/nav" %>
13
+ <main class="orbit-main">
14
+ <% if flash[:warning] %>
15
+ <div class="orbit-flash orbit-flash--warning"><%= flash[:warning] %></div>
16
+ <% end %>
17
+ <%= yield %>
18
+ </main>
19
+ </body>
20
+ </html>
@@ -0,0 +1,17 @@
1
+ <div class="orbit-card orbit-card--bordered orbit-card--border-<%= border %>">
2
+ <div class="orbit-card__header">
3
+ <div class="orbit-card__label">
4
+ <%= orbit_icon(icon) %>
5
+ <%= label %>
6
+ </div>
7
+ <%= render "rails_orbit/shared/delta", delta: delta %>
8
+ </div>
9
+ <div class="orbit-card__value <%= defined?(value_class) ? value_class : '' %>" id="<%= target_id %>">
10
+ <%= render partial: partial, locals: locals %>
11
+ </div>
12
+ <div class="orbit-card__footer">
13
+ <% footer_items.each do |item| %>
14
+ <span class="orbit-card__detail"><%= item %></span>
15
+ <% end %>
16
+ </div>
17
+ </div>
@@ -0,0 +1,58 @@
1
+ <div class="orbit-page">
2
+ <div class="orbit-page-header">
3
+ <h1 class="orbit-page__title">Cache</h1>
4
+ <%= render "rails_orbit/shared/range_picker" %>
5
+ </div>
6
+
7
+ <div class="orbit-grid orbit-grid--3">
8
+ <div class="orbit-card orbit-card--compact">
9
+ <div class="orbit-card__label">Total Reads (<%= range_label %>)</div>
10
+ <div class="orbit-card__value"><%= @reads %></div>
11
+ <div class="orbit-card__footer">
12
+ <span class="orbit-card__detail orbit-card__detail--success"><%= @hits %> hits</span>
13
+ <span class="orbit-card__detail orbit-card__detail--danger"><%= @misses %> misses</span>
14
+ </div>
15
+ </div>
16
+
17
+ <div class="orbit-card orbit-card--compact">
18
+ <div class="orbit-card__label">Hit Rate (<%= range_label %>)</div>
19
+ <div class="orbit-card__value"><%= @hit_rate %><span class="orbit-card__unit">%</span></div>
20
+ <div class="orbit-hit-bar" title="<%= @hit_rate %>% hit rate">
21
+ <div class="orbit-hit-bar__fill" style="width: <%= @hit_rate %>%"></div>
22
+ </div>
23
+ </div>
24
+
25
+ <div class="orbit-card orbit-card--compact">
26
+ <div class="orbit-card__label">Writes (<%= range_label %>)</div>
27
+ <div class="orbit-card__value"><%= @writes %></div>
28
+ </div>
29
+ </div>
30
+
31
+ <div class="orbit-grid orbit-grid--3">
32
+ <div class="orbit-card orbit-card--compact">
33
+ <div class="orbit-card__label">Deletes (<%= range_label %>)</div>
34
+ <div class="orbit-card__value"><%= @deletes %></div>
35
+ </div>
36
+
37
+ <div class="orbit-card orbit-card--compact">
38
+ <div class="orbit-card__label">Fetch Hits (<%= range_label %>)</div>
39
+ <div class="orbit-card__value"><%= @fetch_hit %></div>
40
+ <div class="orbit-card__footer">
41
+ <span class="orbit-card__detail">Block-level cache hits</span>
42
+ </div>
43
+ </div>
44
+
45
+ <div class="orbit-card orbit-card--compact">
46
+ <div class="orbit-card__label">Miss Rate</div>
47
+ <div class="orbit-card__value <%= 'orbit-card__value--warning' if @reads > 0 && (100 - @hit_rate) > 30 %>"><%= @reads.zero? ? 0 : (100 - @hit_rate).round(1) %><span class="orbit-card__unit">%</span></div>
48
+ </div>
49
+ </div>
50
+
51
+ <% if @reads.zero? && @writes.zero? %>
52
+ <div class="orbit-empty">
53
+ <svg class="orbit-empty__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14a9 3 0 0 0 18 0V5"/><path d="M3 12a9 3 0 0 0 18 0"/></svg>
54
+ <p>No cache activity in the selected time range.</p>
55
+ <p class="orbit-empty__hint">Cache events will appear here once solid_cache processes read/write operations.</p>
56
+ </div>
57
+ <% end %>
58
+ </div>
@@ -0,0 +1,54 @@
1
+ <div class="orbit-page">
2
+ <div class="orbit-page-header">
3
+ <h1 class="orbit-page__title">Errors</h1>
4
+ <%= render "rails_orbit/shared/range_picker" %>
5
+ </div>
6
+
7
+ <% if @grouped_errors.any? %>
8
+ <% @grouped_errors.each do |group| %>
9
+ <div class="orbit-error-group">
10
+ <div class="orbit-error-group__header">
11
+ <div class="orbit-error-group__title">
12
+ <span class="orbit-error-group__class"><%= group[:exception_class] %></span>
13
+ <span class="orbit-error-group__badge"><%= group[:count] %>x</span>
14
+ <% if group[:resolved] %>
15
+ <span class="orbit-error-group__resolved">resolved</span>
16
+ <% end %>
17
+ </div>
18
+ <div class="orbit-error-group__meta">
19
+ Last seen <%= time_ago_in_words(group[:last_seen]) %> ago
20
+ </div>
21
+ </div>
22
+
23
+ <div class="orbit-table-wrap">
24
+ <table class="orbit-table orbit-table--compact">
25
+ <thead>
26
+ <tr>
27
+ <th>Message</th>
28
+ <th class="orbit-table__right">Occurred At</th>
29
+ </tr>
30
+ </thead>
31
+ <tbody>
32
+ <% group[:records].each do |error| %>
33
+ <tr>
34
+ <td><%= truncate(error.message, length: 140) %></td>
35
+ <td class="orbit-table__right orbit-table__mono"><%= error.created_at.strftime("%b %d %H:%M:%S") %></td>
36
+ </tr>
37
+ <% end %>
38
+ </tbody>
39
+ </table>
40
+ </div>
41
+ </div>
42
+ <% end %>
43
+ <% else %>
44
+ <div class="orbit-empty">
45
+ <svg class="orbit-empty__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"/><path d="M8 15s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></svg>
46
+ <% if flash[:warning] %>
47
+ <p><%= flash[:warning] %></p>
48
+ <% else %>
49
+ <p>No errors recorded in the selected time range.</p>
50
+ <p class="orbit-empty__hint">Exceptions will appear here once solid_errors captures them.</p>
51
+ <% end %>
52
+ </div>
53
+ <% end %>
54
+ </div>
@@ -0,0 +1,64 @@
1
+ <div class="orbit-page">
2
+ <div class="orbit-page-header">
3
+ <h1 class="orbit-page__title">Jobs</h1>
4
+ <%= render "rails_orbit/shared/range_picker" %>
5
+ </div>
6
+
7
+ <div class="orbit-grid orbit-grid--4">
8
+ <div class="orbit-card orbit-card--compact">
9
+ <div class="orbit-card__label">Enqueued (<%= range_label %>)</div>
10
+ <div class="orbit-card__value"><%= @total_enqueued %></div>
11
+ </div>
12
+ <div class="orbit-card orbit-card--compact">
13
+ <div class="orbit-card__label">Avg Duration</div>
14
+ <div class="orbit-card__value"><%= @avg_duration %><span class="orbit-card__unit">ms</span></div>
15
+ </div>
16
+ <div class="orbit-card orbit-card--compact">
17
+ <div class="orbit-card__label">Failed</div>
18
+ <div class="orbit-card__value <%= 'orbit-card__value--danger' unless @total_failed.zero? %>"><%= @total_failed %></div>
19
+ </div>
20
+ <div class="orbit-card orbit-card--compact">
21
+ <div class="orbit-card__label">Discarded</div>
22
+ <div class="orbit-card__value <%= 'orbit-card__value--warning' unless @total_discarded.zero? %>"><%= @total_discarded %></div>
23
+ </div>
24
+ </div>
25
+
26
+ <% if @by_queue.any? %>
27
+ <div class="orbit-table-wrap">
28
+ <table class="orbit-table">
29
+ <thead>
30
+ <tr>
31
+ <th>Queue</th>
32
+ <th class="orbit-table__right">Enqueued</th>
33
+ <th class="orbit-table__right">Avg Duration</th>
34
+ <th class="orbit-table__right">Failed</th>
35
+ <th class="orbit-table__right">Retried</th>
36
+ <th class="orbit-table__right">Discarded</th>
37
+ </tr>
38
+ </thead>
39
+ <tbody>
40
+ <%
41
+ queues = @by_queue.keys.map(&:first).uniq.compact
42
+ queues.each do |queue|
43
+ failed_count = @by_queue[[queue, "solid_queue.failed"]]&.to_i || 0
44
+ %>
45
+ <tr class="<%= 'orbit-table__row--danger' if failed_count > 0 %>">
46
+ <td class="orbit-table__queue"><%= queue || "default" %></td>
47
+ <td class="orbit-table__right orbit-table__mono"><%= @by_queue[[queue, "solid_queue.enqueued"]]&.to_i || 0 %></td>
48
+ <td class="orbit-table__right orbit-table__mono"><%= @by_queue[[queue, "solid_queue.performed_ms"]]&.round(1) || 0 %><span class="orbit-table__unit">ms</span></td>
49
+ <td class="orbit-table__right orbit-table__mono <%= 'orbit-table__danger' if failed_count > 0 %>"><%= failed_count %></td>
50
+ <td class="orbit-table__right orbit-table__mono"><%= @by_queue[[queue, "solid_queue.retried"]]&.to_i || 0 %></td>
51
+ <td class="orbit-table__right orbit-table__mono"><%= @by_queue[[queue, "solid_queue.discarded"]]&.to_i || 0 %></td>
52
+ </tr>
53
+ <% end %>
54
+ </tbody>
55
+ </table>
56
+ </div>
57
+ <% else %>
58
+ <div class="orbit-empty">
59
+ <svg class="orbit-empty__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8"/><path d="M12 17v4"/><path d="M7 10h0"/><path d="M12 10h0"/><path d="M17 10h0"/></svg>
60
+ <p>No job metrics recorded in the selected time range.</p>
61
+ <p class="orbit-empty__hint">Jobs will appear here once solid_queue starts processing work.</p>
62
+ </div>
63
+ <% end %>
64
+ </div>
@@ -0,0 +1,67 @@
1
+ <div class="orbit-page" data-controller="orbit-poll" data-orbit-poll-url-value="<%= rails_orbit.stream_path %>" data-orbit-poll-interval-value="<%= RailsOrbit.configuration.poll_interval * 1000 %>">
2
+ <div class="orbit-page-header">
3
+ <h1 class="orbit-page__title">Overview</h1>
4
+ <div class="orbit-page-header__right">
5
+ <%= render "rails_orbit/shared/range_picker" %>
6
+ <div class="orbit-updated" id="orbit-last-updated" data-time="<%= Time.current.iso8601 %>">
7
+ Updated just now
8
+ </div>
9
+ </div>
10
+ </div>
11
+
12
+ <div class="orbit-grid orbit-grid--3">
13
+ <%= render "rails_orbit/dashboard/overview_card",
14
+ border: "primary",
15
+ icon: :jobs,
16
+ label: "Jobs (#{range_label})",
17
+ delta: @enqueued_delta,
18
+ target_id: "orbit-queue-stats",
19
+ partial: "rails_orbit/stream/queue_stats",
20
+ locals: { data: { enqueued: @enqueued_count, failed: @failed_jobs_count, retried: @retried_count, avg_ms: @avg_duration } },
21
+ footer_items: ["#{@discarded_count} discarded", "#{@avg_duration}ms avg"] %>
22
+
23
+ <%= render "rails_orbit/dashboard/overview_card",
24
+ border: cache_severity(@cache_hit_rate),
25
+ icon: :cache,
26
+ label: "Cache (#{range_label})",
27
+ delta: @cache_delta,
28
+ target_id: "orbit-cache-stats",
29
+ partial: "rails_orbit/stream/cache_stats",
30
+ locals: { data: { hit_rate: @cache_hit_rate, hits: @cache_hits, misses: @cache_misses, writes: @cache_writes } },
31
+ footer_items: ["#{@cache_hits} hits", "#{@cache_misses} misses", "#{@cache_writes} writes"] %>
32
+
33
+ <%= render "rails_orbit/dashboard/overview_card",
34
+ border: severity_class(@error_count),
35
+ icon: :errors,
36
+ label: "Errors (#{range_label})",
37
+ delta: @error_delta,
38
+ target_id: "orbit-error-count",
39
+ partial: "rails_orbit/stream/error_count",
40
+ locals: { count: @error_count },
41
+ value_class: (@error_count.zero? ? "" : "orbit-card__value--danger"),
42
+ footer_items: ["#{@failed_jobs_count} failed jobs", "#{@retried_count} retried"] %>
43
+ </div>
44
+
45
+ <div class="orbit-grid orbit-grid--1">
46
+ <% if @job_chart.any? %>
47
+ <div class="orbit-card orbit-card--wide">
48
+ <div class="orbit-card__label">Job Duration (avg ms)</div>
49
+ <div class="orbit-chart-interactive" data-chart='<%= @job_chart.to_json %>' data-unit="ms" data-color="var(--orbit-primary)" data-gradient-id="orbit-grad-jobs"></div>
50
+ </div>
51
+ <% end %>
52
+
53
+ <% if @cache_chart.any? %>
54
+ <div class="orbit-card orbit-card--wide">
55
+ <div class="orbit-card__label">Cache Hit Rate (%)</div>
56
+ <div class="orbit-chart-interactive" data-chart='<%= @cache_chart.to_json %>' data-unit="%" data-color="var(--orbit-success)" data-gradient-id="orbit-grad-cache"></div>
57
+ </div>
58
+ <% end %>
59
+
60
+ <% if @error_chart.any? %>
61
+ <div class="orbit-card orbit-card--wide">
62
+ <div class="orbit-card__label">Errors</div>
63
+ <div class="orbit-chart-interactive" data-chart='<%= @error_chart.to_json %>' data-unit="" data-color="var(--orbit-danger)" data-gradient-id="orbit-grad-errors"></div>
64
+ </div>
65
+ <% end %>
66
+ </div>
67
+ </div>
@@ -0,0 +1,14 @@
1
+ <% title = defined?(@range_key) ? delta_title(@range_key) : "vs previous period" %>
2
+ <% if delta[:direction] == :up %>
3
+ <span class="orbit-delta orbit-delta--up" title="<%= title %>">
4
+ <%= orbit_icon(:delta_up) %>
5
+ <%= delta[:value] %>%
6
+ </span>
7
+ <% elsif delta[:direction] == :down %>
8
+ <span class="orbit-delta orbit-delta--down" title="<%= title %>">
9
+ <%= orbit_icon(:delta_down) %>
10
+ <%= delta[:value] %>%
11
+ </span>
12
+ <% else %>
13
+ <span class="orbit-delta orbit-delta--flat" title="<%= title %>">--</span>
14
+ <% end %>
@@ -0,0 +1,26 @@
1
+ <nav class="orbit-nav">
2
+ <div class="orbit-nav__inner">
3
+ <div class="orbit-nav__brand">
4
+ <%= orbit_icon(:orbit) %>
5
+ <span class="orbit-nav__title"><%= RailsOrbit.configuration.dashboard_title %></span>
6
+ </div>
7
+ <div class="orbit-nav__links">
8
+ <%= link_to rails_orbit.root_path, class: "orbit-nav__link #{'orbit-nav__link--active' if current_page?(rails_orbit.root_path)}" do %>
9
+ <%= orbit_icon(:overview, css_class: "orbit-nav__icon") %>
10
+ Overview
11
+ <% end %>
12
+ <%= link_to rails_orbit.jobs_path, class: "orbit-nav__link #{'orbit-nav__link--active' if current_page?(rails_orbit.jobs_path)}" do %>
13
+ <%= orbit_icon(:jobs, css_class: "orbit-nav__icon") %>
14
+ Jobs
15
+ <% end %>
16
+ <%= link_to rails_orbit.cache_path, class: "orbit-nav__link #{'orbit-nav__link--active' if current_page?(rails_orbit.cache_path)}" do %>
17
+ <%= orbit_icon(:cache, css_class: "orbit-nav__icon") %>
18
+ Cache
19
+ <% end %>
20
+ <%= link_to rails_orbit.errors_path, class: "orbit-nav__link #{'orbit-nav__link--active' if current_page?(rails_orbit.errors_path)}" do %>
21
+ <%= orbit_icon(:errors, css_class: "orbit-nav__icon") %>
22
+ Errors
23
+ <% end %>
24
+ </div>
25
+ </div>
26
+ </nav>
@@ -0,0 +1,5 @@
1
+ <div class="orbit-range-picker">
2
+ <% range_options.each do |key, cfg| %>
3
+ <%= link_to cfg[:label], url_for(range: key), class: "orbit-range-picker__btn #{'orbit-range-picker__btn--active' if @range_key == key}" %>
4
+ <% end %>
5
+ </div>
@@ -0,0 +1,9 @@
1
+ <div class="orbit-stat-group">
2
+ <span class="orbit-stat"><%= data[:hit_rate] %><span class="orbit-card__unit">%</span></span>
3
+ <span class="orbit-stat--label">hit rate</span>
4
+ </div>
5
+ <div class="orbit-stat-row">
6
+ <span class="orbit-stat-pill orbit-stat-pill--success"><%= data[:hits] %> hits</span>
7
+ <span class="orbit-stat-pill orbit-stat-pill--danger"><%= data[:misses] %> misses</span>
8
+ <span class="orbit-stat-pill"><%= data[:writes] %> writes</span>
9
+ </div>
@@ -0,0 +1 @@
1
+ <span class="orbit-stat <%= count.zero? ? 'orbit-stat--success' : (count < 10 ? 'orbit-stat--warning' : 'orbit-stat--danger') %>"><%= count %></span>
@@ -0,0 +1,8 @@
1
+ <div class="orbit-stat-group">
2
+ <span class="orbit-stat"><%= data[:enqueued] %></span>
3
+ <span class="orbit-stat--label">enqueued</span>
4
+ </div>
5
+ <div class="orbit-stat-row">
6
+ <span class="orbit-stat-pill orbit-stat-pill--danger"><%= data[:failed] %> failed</span>
7
+ <span class="orbit-stat-pill"><%= data[:retried] %> retried</span>
8
+ </div>
@@ -0,0 +1,11 @@
1
+ <%= turbo_stream.update "orbit-queue-stats" do %>
2
+ <%= render "rails_orbit/stream/queue_stats", data: @queue_data %>
3
+ <% end %>
4
+
5
+ <%= turbo_stream.update "orbit-cache-stats" do %>
6
+ <%= render "rails_orbit/stream/cache_stats", data: @cache_data %>
7
+ <% end %>
8
+
9
+ <%= turbo_stream.update "orbit-error-count" do %>
10
+ <%= render "rails_orbit/stream/error_count", count: @error_count %>
11
+ <% end %>
data/config/routes.rb ADDED
@@ -0,0 +1,9 @@
1
+ RailsOrbit::Engine.routes.draw do
2
+ root to: "dashboard#overview"
3
+
4
+ get "jobs", to: "dashboard#jobs", as: :jobs
5
+ get "cache", to: "dashboard#cache", as: :cache
6
+ get "errors", to: "dashboard#errors", as: :errors
7
+
8
+ get "stream", to: "stream#index", as: :stream
9
+ end
@@ -0,0 +1,55 @@
1
+ require "rails/generators"
2
+ require "rails/generators/active_record"
3
+
4
+ module RailsOrbit
5
+ module Generators
6
+ class InstallGenerator < Rails::Generators::Base
7
+ include ActiveRecord::Generators::Migration
8
+
9
+ source_root File.expand_path("templates", __dir__)
10
+
11
+ desc "Installs rails_orbit: copies migrations, creates initializer, suggests route mount."
12
+
13
+ def create_initializer
14
+ template "initializer.rb", "config/initializers/rails_orbit.rb"
15
+ end
16
+
17
+ def copy_migrations
18
+ migration_template(
19
+ "create_orbit_metrics.rb.erb",
20
+ "db/migrate/create_rails_orbit_metrics.rb"
21
+ )
22
+ end
23
+
24
+ def mount_route
25
+ route 'mount RailsOrbit::Engine, at: "/orbit"'
26
+ end
27
+
28
+ def print_next_steps
29
+ say "\n"
30
+ say "rails_orbit installed!", :green
31
+ say ""
32
+ say "Next steps:"
33
+ say ""
34
+ say " For :host_db or :external adapters:"
35
+ say " bin/rails db:migrate"
36
+ say ""
37
+ say " For :sqlite adapter (default):"
38
+ say " The table is created automatically on first boot."
39
+ say " Or run manually: bin/rails rails_orbit:setup"
40
+ say ""
41
+ say " Then:"
42
+ say " 1. Edit config/initializers/rails_orbit.rb"
43
+ say " 2. Set ORBIT_USER and ORBIT_PASSWORD in your environment"
44
+ say " 3. Visit /orbit in your browser"
45
+ say ""
46
+ say " Useful commands:"
47
+ say " bin/rails rails_orbit:setup — create the metrics table"
48
+ say " bin/rails rails_orbit:status — check config and table status"
49
+ say ""
50
+ say "If you are deploying to Heroku or another ephemeral platform:", :yellow
51
+ say " Set config.storage_adapter = :host_db in the initializer.", :yellow
52
+ end
53
+ end
54
+ end
55
+ end