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
|
@@ -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,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,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,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
|