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,14 @@
1
+ class CreateRailsOrbitMetrics < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ create_table :rails_orbit_metrics do |t|
4
+ t.string :key, null: false, limit: 255
5
+ t.float :value, null: false
6
+ t.string :dimension, limit: 255
7
+ t.datetime :recorded_at, null: false, precision: 6
8
+ end
9
+
10
+ add_index :rails_orbit_metrics, [:key, :recorded_at]
11
+ add_index :rails_orbit_metrics, :recorded_at
12
+ add_index :rails_orbit_metrics, [:key, :recorded_at, :value], name: "idx_rails_orbit_metrics_cover"
13
+ end
14
+ end
@@ -0,0 +1,31 @@
1
+ RailsOrbit.configure do |config|
2
+ # ── Storage ───────────────────────────────────────────────────────────────
3
+ # :sqlite → writes to db/rails_orbit.sqlite3 (default)
4
+ # :host_db → writes to host app's primary DB, all tables prefixed orbit_
5
+ # :external → provide config.storage_url below
6
+ config.storage_adapter = :sqlite
7
+
8
+ # Required only when storage_adapter is :external
9
+ # config.storage_url = ENV["ORBIT_DATABASE_URL"]
10
+
11
+ # ── Authentication ────────────────────────────────────────────────────────
12
+ # Provide a block that will be called as a before_action on the dashboard.
13
+ # Default: HTTP Basic Auth via ORBIT_USER / ORBIT_PASSWORD env vars.
14
+ # config.authenticate_with do |controller|
15
+ # controller.authenticate_or_request_with_http_basic("Orbit") do |name, password|
16
+ # ActiveSupport::SecurityUtils.secure_compare(name, ENV.fetch("ORBIT_USER", "orbit")) &
17
+ # ActiveSupport::SecurityUtils.secure_compare(password, ENV.fetch("ORBIT_PASSWORD", "changeme"))
18
+ # end
19
+ # end
20
+
21
+ # ── Data retention ────────────────────────────────────────────────────────
22
+ config.retention_days = 7
23
+
24
+ # ── Kamal integration (disabled by default) ───────────────────────────────
25
+ config.kamal_enabled = false
26
+ config.kamal_ssh_key_path = nil
27
+
28
+ # ── Dashboard ─────────────────────────────────────────────────────────────
29
+ config.dashboard_title = "Orbit"
30
+ config.poll_interval = 5
31
+ end
@@ -0,0 +1,73 @@
1
+ module RailsOrbit
2
+ class Configuration
3
+ VALID_ADAPTERS = %i[sqlite host_db external].freeze
4
+
5
+ attr_accessor :storage_adapter, :storage_url, :retention_days,
6
+ :kamal_enabled, :kamal_ssh_key_path,
7
+ :dashboard_title, :poll_interval
8
+ attr_reader :auth_block
9
+
10
+ def initialize
11
+ @storage_adapter = :sqlite
12
+ @storage_url = nil
13
+ @retention_days = 7
14
+ @kamal_enabled = false
15
+ @kamal_ssh_key_path = nil
16
+ @dashboard_title = "Orbit"
17
+ @poll_interval = 5
18
+ @auth_block = default_auth_block
19
+ end
20
+
21
+ def authenticate_with(&block)
22
+ @auth_block = block
23
+ end
24
+
25
+ def validate!
26
+ validate_storage_adapter!
27
+ validate_external_url!
28
+ validate_kamal_ssh_key!
29
+ end
30
+
31
+ private
32
+
33
+ def validate_storage_adapter!
34
+ return if VALID_ADAPTERS.include?(@storage_adapter)
35
+ raise ArgumentError, "Unknown storage_adapter: #{@storage_adapter.inspect}. Valid options: #{VALID_ADAPTERS.join(', ')}"
36
+ end
37
+
38
+ def validate_external_url!
39
+ return unless @storage_adapter == :external && @storage_url.blank?
40
+ raise ArgumentError, "storage_adapter is :external but storage_url is not set."
41
+ end
42
+
43
+ def validate_kamal_ssh_key!
44
+ return unless @kamal_enabled
45
+ return if @kamal_ssh_key_path || ENV["ORBIT_SSH_KEY_PATH"]
46
+ raise ArgumentError, "kamal_enabled is true but kamal_ssh_key_path is not set. " \
47
+ "Set it in the initializer or via ORBIT_SSH_KEY_PATH env var."
48
+ end
49
+
50
+ def default_auth_block
51
+ ->(controller) {
52
+ user = ENV["ORBIT_USER"]
53
+ password = ENV["ORBIT_PASSWORD"]
54
+
55
+ if user.nil? || password.nil?
56
+ if Rails.env.production?
57
+ Rails.logger.error("[rails_orbit] ORBIT_USER and ORBIT_PASSWORD must be set in production. Dashboard access denied.")
58
+ controller.head(:forbidden)
59
+ return
60
+ else
61
+ user ||= "orbit"
62
+ password ||= "orbit"
63
+ end
64
+ end
65
+
66
+ controller.authenticate_or_request_with_http_basic("Orbit") do |name, pwd|
67
+ ActiveSupport::SecurityUtils.secure_compare(name, user) &
68
+ ActiveSupport::SecurityUtils.secure_compare(pwd, password)
69
+ end
70
+ }
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,87 @@
1
+ module RailsOrbit
2
+ class DatabaseSetup
3
+ attr_reader :connection
4
+
5
+ def initialize(connection = ApplicationRecord.connection)
6
+ @connection = connection
7
+ end
8
+
9
+ def run!
10
+ configure_sqlite! if sqlite?
11
+ create_table_unless_exists!
12
+ end
13
+
14
+ private
15
+
16
+ def sqlite?
17
+ adapter_name.include?("sqlite")
18
+ end
19
+
20
+ def postgres?
21
+ adapter_name.include?("postgres")
22
+ end
23
+
24
+ def adapter_name
25
+ connection.adapter_name.downcase
26
+ end
27
+
28
+ def configure_sqlite!
29
+ %w[journal_mode=WAL busy_timeout=5000 synchronous=NORMAL].each do |pragma|
30
+ connection.raw_connection.execute("PRAGMA #{pragma}")
31
+ end
32
+ rescue => e
33
+ Rails.logger.debug("[rails_orbit] SQLite PRAGMA setup: #{e.message}")
34
+ end
35
+
36
+ def create_table_unless_exists!
37
+ table = Metric.table_name
38
+ return if connection.table_exists?(table)
39
+
40
+ connection.execute(create_table_ddl(table))
41
+ add_indexes!(table)
42
+ Rails.logger.info("[rails_orbit] Created #{table} table (adapter: #{adapter_name})")
43
+ end
44
+
45
+ def create_table_ddl(table)
46
+ if sqlite?
47
+ <<~SQL
48
+ CREATE TABLE IF NOT EXISTS #{table} (
49
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
50
+ key VARCHAR(255) NOT NULL,
51
+ value FLOAT NOT NULL,
52
+ dimension VARCHAR(255),
53
+ recorded_at DATETIME NOT NULL
54
+ )
55
+ SQL
56
+ elsif postgres?
57
+ <<~SQL
58
+ CREATE TABLE IF NOT EXISTS #{table} (
59
+ id BIGSERIAL PRIMARY KEY,
60
+ key VARCHAR(255) NOT NULL,
61
+ value DOUBLE PRECISION NOT NULL,
62
+ dimension VARCHAR(255),
63
+ recorded_at TIMESTAMP(6) NOT NULL
64
+ )
65
+ SQL
66
+ else
67
+ <<~SQL
68
+ CREATE TABLE IF NOT EXISTS #{table} (
69
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
70
+ key VARCHAR(255) NOT NULL,
71
+ value DOUBLE NOT NULL,
72
+ dimension VARCHAR(255),
73
+ recorded_at DATETIME(6) NOT NULL
74
+ )
75
+ SQL
76
+ end
77
+ end
78
+
79
+ def add_indexes!(table)
80
+ { key_rec: "(key, recorded_at)", rec: "(recorded_at)", cover: "(key, recorded_at, value)" }.each do |name, cols|
81
+ connection.execute("CREATE INDEX IF NOT EXISTS idx_#{table}_#{name} ON #{table} #{cols}")
82
+ rescue StandardError
83
+ nil
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,80 @@
1
+ module RailsOrbit
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace RailsOrbit
4
+
5
+ rake_tasks do
6
+ load root.join("lib", "tasks", "rails_orbit.rake")
7
+ end
8
+
9
+ initializer "rails_orbit.establish_connection", after: :load_active_record do
10
+ config.after_initialize do
11
+ orbit_config = RailsOrbit.configuration
12
+
13
+ conn_spec = case orbit_config.storage_adapter
14
+ when :sqlite
15
+ db_path = Rails.root.join("db", "rails_orbit.sqlite3")
16
+ { adapter: "sqlite3", database: db_path.to_s, timeout: 5000 }
17
+ when :host_db
18
+ ActiveRecord::Base.connection_db_config.configuration_hash.dup
19
+ when :external
20
+ { url: orbit_config.storage_url }
21
+ end
22
+
23
+ ApplicationRecord.establish_connection(conn_spec)
24
+ DatabaseSetup.new.run!
25
+ rescue => e
26
+ Rails.logger.warn("[rails_orbit] Database setup failed: #{e.message}. Run `bin/rails rails_orbit:setup` manually.")
27
+ end
28
+ end
29
+
30
+ initializer "rails_orbit.assets" do |app|
31
+ if app.config.respond_to?(:assets)
32
+ app.config.assets.precompile += %w[rails_orbit/application.css rails_orbit/application.js]
33
+ end
34
+ end
35
+
36
+ initializer "rails_orbit.static_assets" do |app|
37
+ app.middleware.insert_before(::ActionDispatch::Static, ::ActionDispatch::Static, root.join("public").to_s)
38
+ end
39
+
40
+ initializer "rails_orbit.ephemeral_warning" do
41
+ next unless RailsOrbit.configuration.storage_adapter == :sqlite
42
+
43
+ signals = %w[DYNO RAILWAY_ENVIRONMENT FLY_APP_NAME]
44
+ next unless signals.any? { |k| ENV.key?(k) }
45
+
46
+ platform = ENV["DYNO"] ? "Heroku" : ENV["RAILWAY_ENVIRONMENT"] ? "Railway" : "Fly.io"
47
+ db_path = Rails.root.join("db", "rails_orbit.sqlite3")
48
+ msg = "[rails_orbit] WARNING: Using :sqlite on #{platform} (ephemeral filesystem). " \
49
+ "#{db_path} will be destroyed on deploy. Use :host_db or :external instead."
50
+ Rails.logger.warn(msg)
51
+ warn(msg)
52
+ end
53
+
54
+ initializer "rails_orbit.instrumentation", after: "rails_orbit.establish_connection" do
55
+ ActiveSupport.on_load(:after_initialize) { Instrumentation.subscribe! }
56
+ end
57
+
58
+ initializer "rails_orbit.kamal_poller", after: "rails_orbit.instrumentation" do
59
+ ActiveSupport.on_load(:after_initialize) do
60
+ next unless RailsOrbit.configuration.kamal_enabled
61
+ require "rails_orbit/kamal/config_reader"
62
+ require "rails_orbit/kamal/stats_collector"
63
+ require "rails_orbit/kamal/poller"
64
+ Kamal::Poller.start!
65
+ end
66
+ end
67
+
68
+ initializer "rails_orbit.puma_fork_safety" do
69
+ config.after_initialize do
70
+ next unless defined?(Puma) && Puma.respond_to?(:cli_config)
71
+ Puma.cli_config.options[:before_worker_boot] ||= []
72
+ Puma.cli_config.options[:before_worker_boot] << ->(_) { MetricWriter.reset! }
73
+ end
74
+ end
75
+
76
+ initializer "rails_orbit.shutdown_hook" do
77
+ at_exit { MetricWriter.shutdown if defined?(MetricWriter) }
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,83 @@
1
+ module RailsOrbit
2
+ module Instrumentation
3
+ SUBSCRIPTIONS = [
4
+ "enqueue.solid_queue",
5
+ "perform.solid_queue",
6
+ "failed_execution.solid_queue",
7
+ "retry_execution.solid_queue",
8
+ "discard_job.solid_queue",
9
+ "cache_read.active_support",
10
+ "cache_write.active_support",
11
+ "cache_delete.active_support",
12
+ "cache_fetch_hit.active_support",
13
+ "error_recorded.solid_errors",
14
+ ].freeze
15
+
16
+ class << self
17
+ def subscribe!
18
+ unsubscribe! if @subscriptions
19
+ @subscriptions = SUBSCRIPTIONS.map do |event_name|
20
+ ActiveSupport::Notifications.subscribe(event_name) do |event|
21
+ handle(event)
22
+ end
23
+ end
24
+ end
25
+
26
+ def unsubscribe!
27
+ Array(@subscriptions).each do |sub|
28
+ ActiveSupport::Notifications.unsubscribe(sub)
29
+ end
30
+ @subscriptions = nil
31
+ end
32
+
33
+ def subscribed?
34
+ @subscriptions.present?
35
+ end
36
+
37
+ def handle(event)
38
+ key, value, dimension = translate(event)
39
+ return unless key
40
+ MetricWriter.write(key: key, value: value, dimension: dimension)
41
+ end
42
+
43
+ def translate(event)
44
+ case event.name
45
+ when "enqueue.solid_queue"
46
+ ["solid_queue.enqueued", 1, event.payload[:queue_name]]
47
+
48
+ when "perform.solid_queue"
49
+ duration_ms = event.duration.round(2)
50
+ ["solid_queue.performed_ms", duration_ms, event.payload[:queue_name]]
51
+
52
+ when "failed_execution.solid_queue"
53
+ ["solid_queue.failed", 1, event.payload[:queue_name]]
54
+
55
+ when "retry_execution.solid_queue"
56
+ ["solid_queue.retried", 1, event.payload[:queue_name]]
57
+
58
+ when "discard_job.solid_queue"
59
+ ["solid_queue.discarded", 1, event.payload[:queue_name]]
60
+
61
+ when "cache_read.active_support"
62
+ hit = event.payload[:hit] ? "hit" : "miss"
63
+ ["solid_cache.read_#{hit}", 1, event.payload[:store]]
64
+
65
+ when "cache_write.active_support"
66
+ ["solid_cache.write", 1, event.payload[:store]]
67
+
68
+ when "cache_delete.active_support"
69
+ ["solid_cache.delete", 1, event.payload[:store]]
70
+
71
+ when "cache_fetch_hit.active_support"
72
+ ["solid_cache.fetch_hit", 1, event.payload[:store]]
73
+
74
+ when "error_recorded.solid_errors"
75
+ ["solid_errors.recorded", 1, event.payload[:exception_class]]
76
+
77
+ else
78
+ nil
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,32 @@
1
+ module RailsOrbit
2
+ module Kamal
3
+ class ConfigReader
4
+ DEPLOY_YML = "config/deploy.yml"
5
+
6
+ class << self
7
+ def load
8
+ @config ||= begin
9
+ path = Rails.root.join(DEPLOY_YML)
10
+ raise "Kamal config not found at #{path}" unless path.exist?
11
+ YAML.safe_load_file(path, permitted_classes: [Symbol])
12
+ end
13
+ end
14
+
15
+ def reload!
16
+ @config = nil
17
+ load
18
+ end
19
+
20
+ def servers
21
+ config = load
22
+ Array(config.dig("servers", "web")) +
23
+ Array(config.dig("servers", "workers"))
24
+ end
25
+
26
+ def ssh_user
27
+ load.dig("ssh", "user") || "root"
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,64 @@
1
+ require "concurrent"
2
+
3
+ module RailsOrbit
4
+ module Kamal
5
+ class Poller
6
+ POLL_INTERVAL = 30
7
+
8
+ def self.available?
9
+ return @available if defined?(@available)
10
+ @available = begin
11
+ require "sshkit"
12
+ require "sshkit/dsl"
13
+ true
14
+ rescue LoadError
15
+ false
16
+ end
17
+ end
18
+
19
+ def self.start!
20
+ unless available?
21
+ Rails.logger.warn "[rails_orbit] Kamal polling requires the sshkit gem. Add it to your Gemfile."
22
+ return
23
+ end
24
+ new.schedule
25
+ end
26
+
27
+ def schedule
28
+ @task = Concurrent::TimerTask.new(
29
+ execution_interval: POLL_INTERVAL,
30
+ run_now: true
31
+ ) { run }
32
+ @task.execute
33
+ end
34
+
35
+ def stop
36
+ @task&.shutdown
37
+ end
38
+
39
+ def run
40
+ config = RailsOrbit.configuration
41
+ servers = ConfigReader.servers
42
+ user = ConfigReader.ssh_user
43
+ key_path = config.kamal_ssh_key_path || ENV["ORBIT_SSH_KEY_PATH"]
44
+
45
+ unless key_path
46
+ Rails.logger.error "[rails_orbit] kamal_ssh_key_path must be set when kamal_enabled is true"
47
+ return
48
+ end
49
+
50
+ collector = StatsCollector.new
51
+ servers.each do |host|
52
+ stats = collector.collect(host: host, user: user, ssh_key_path: key_path)
53
+ stats.each do |s|
54
+ dimension = "#{s[:host]}/#{s[:container]}"
55
+ MetricWriter.write(key: "kamal.cpu_pct", value: s[:cpu_pct], dimension: dimension)
56
+ MetricWriter.write(key: "kamal.mem_pct", value: s[:mem_pct], dimension: dimension)
57
+ end
58
+ end
59
+ rescue => e
60
+ Rails.logger.error "[rails_orbit] Kamal poller error: #{e.class} - #{e.message}"
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,42 @@
1
+ module RailsOrbit
2
+ module Kamal
3
+ class StatsCollector
4
+ include SSHKit::DSL
5
+
6
+ def collect(host:, user:, ssh_key_path:)
7
+ require "sshkit"
8
+ require "sshkit/dsl"
9
+
10
+ output = nil
11
+ on(SSHKit::Host.new("#{user}@#{host}")) do
12
+ output = capture(:docker, "stats", "--no-stream", "--format",
13
+ "{{.Name}} {{.CPUPerc}} {{.MemPerc}}")
14
+ end
15
+ parse(output, host: host)
16
+ rescue SSHKit::Command::Failed, SocketError => e
17
+ Rails.logger.error "[rails_orbit] SSH stats failed for #{host}: #{e.message}"
18
+ []
19
+ rescue => e
20
+ Rails.logger.error "[rails_orbit] SSH stats failed for #{host}: #{e.class} - #{e.message}"
21
+ []
22
+ end
23
+
24
+ private
25
+
26
+ def parse(raw, host:)
27
+ raw.to_s.lines.filter_map do |line|
28
+ parts = line.strip.split
29
+ next unless parts.size == 3
30
+ name, cpu, mem = parts
31
+ {
32
+ host: host,
33
+ container: name,
34
+ cpu_pct: cpu.to_f,
35
+ mem_pct: mem.to_f,
36
+ at: Time.current
37
+ }
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,37 @@
1
+ require "concurrent"
2
+
3
+ module RailsOrbit
4
+ module MetricWriter
5
+ class << self
6
+ def executor
7
+ @executor ||= Concurrent::SingleThreadExecutor.new(
8
+ fallback_policy: :discard
9
+ )
10
+ end
11
+
12
+ def write(key:, value:, dimension: nil)
13
+ executor.post do
14
+ RailsOrbit::ApplicationRecord.connection_pool.with_connection do
15
+ RailsOrbit::Metric.insert(
16
+ { key: key, value: value, dimension: dimension, recorded_at: Time.current },
17
+ returning: false
18
+ )
19
+ end
20
+ rescue => e
21
+ Rails.logger.error("[rails_orbit] MetricWriter failed: #{e.message}")
22
+ end
23
+ end
24
+
25
+ def shutdown
26
+ return unless defined?(@executor) && @executor
27
+ executor.shutdown
28
+ executor.wait_for_termination(5)
29
+ end
30
+
31
+ def reset!
32
+ shutdown
33
+ @executor = nil
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,39 @@
1
+ module RailsOrbit
2
+ class TimeRange
3
+ OPTIONS = {
4
+ "1h" => { minutes: 60, bucket_minutes: 1, delta_window: 15.minutes, label: "1h" },
5
+ "6h" => { minutes: 360, bucket_minutes: 5, delta_window: 1.hour, label: "6h" },
6
+ "24h" => { minutes: 1440, bucket_minutes: 15, delta_window: 1.hour, label: "24h" },
7
+ "7d" => { minutes: 10080, bucket_minutes: 60, delta_window: 6.hours, label: "7d" },
8
+ "30d" => { minutes: 43200, bucket_minutes: 360, delta_window: 1.day, label: "30d" },
9
+ }.freeze
10
+
11
+ attr_reader :key
12
+
13
+ def initialize(param)
14
+ @key = OPTIONS.key?(param) ? param : "24h"
15
+ end
16
+
17
+ def since
18
+ config[:minutes].minutes.ago
19
+ end
20
+
21
+ def bucket_minutes
22
+ config[:bucket_minutes]
23
+ end
24
+
25
+ def delta_window
26
+ config[:delta_window]
27
+ end
28
+
29
+ def label
30
+ config[:label]
31
+ end
32
+
33
+ private
34
+
35
+ def config
36
+ OPTIONS[@key]
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,3 @@
1
+ module RailsOrbit
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,24 @@
1
+ require "rails_orbit/version"
2
+ require "rails_orbit/configuration"
3
+ require "rails_orbit/time_range"
4
+ require "rails_orbit/database_setup"
5
+ require "rails_orbit/metric_writer"
6
+ require "rails_orbit/instrumentation"
7
+ require "rails_orbit/engine"
8
+
9
+ module RailsOrbit
10
+ class << self
11
+ def configuration
12
+ @configuration ||= Configuration.new
13
+ end
14
+
15
+ def configure
16
+ yield configuration
17
+ configuration.validate!
18
+ end
19
+
20
+ def reset_configuration!
21
+ @configuration = Configuration.new
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,60 @@
1
+ namespace :rails_orbit do
2
+ desc "Create the rails_orbit_metrics table in the configured database"
3
+ task setup: :environment do
4
+ conn = RailsOrbit::ApplicationRecord.connection
5
+ table = RailsOrbit::Metric.table_name
6
+ config = RailsOrbit.configuration
7
+
8
+ puts "[rails_orbit] Storage adapter: #{config.storage_adapter}"
9
+ puts "[rails_orbit] Database adapter: #{conn.adapter_name.downcase}"
10
+ puts "[rails_orbit] Table name: #{table}"
11
+
12
+ if conn.table_exists?(table)
13
+ puts "[rails_orbit] Table '#{table}' already exists. Nothing to do."
14
+ next
15
+ end
16
+
17
+ RailsOrbit::DatabaseSetup.new(conn).run!
18
+ puts "[rails_orbit] Created '#{table}' table with indexes."
19
+ end
20
+
21
+ desc "Show rails_orbit configuration and table status"
22
+ task status: :environment do
23
+ config = RailsOrbit.configuration
24
+ conn = RailsOrbit::ApplicationRecord.connection
25
+ table = RailsOrbit::Metric.table_name
26
+ adapter = conn.adapter_name.downcase
27
+
28
+ puts ""
29
+ puts "rails_orbit status"
30
+ puts "-" * 40
31
+ puts " Storage adapter: #{config.storage_adapter}"
32
+ puts " Database adapter: #{adapter}"
33
+ puts " Table name: #{table}"
34
+ puts " Table exists: #{conn.table_exists?(table)}"
35
+
36
+ if conn.table_exists?(table)
37
+ count = conn.select_value("SELECT COUNT(*) FROM #{conn.quote_table_name(table)}")
38
+ oldest = conn.select_value("SELECT MIN(recorded_at) FROM #{conn.quote_table_name(table)}")
39
+ newest = conn.select_value("SELECT MAX(recorded_at) FROM #{conn.quote_table_name(table)}")
40
+ puts " Metric count: #{count}"
41
+ puts " Oldest metric: #{oldest || 'none'}"
42
+ puts " Newest metric: #{newest || 'none'}"
43
+ end
44
+
45
+ if config.storage_adapter == :sqlite
46
+ db_path = conn.pool.db_config.configuration_hash[:database]
47
+ puts " SQLite path: #{db_path}"
48
+ if File.exist?(db_path.to_s)
49
+ size_kb = (File.size(db_path.to_s) / 1024.0).round(1)
50
+ puts " SQLite size: #{size_kb} KB"
51
+ end
52
+ end
53
+
54
+ puts " Retention days: #{config.retention_days}"
55
+ puts " Poll interval: #{config.poll_interval}s"
56
+ puts " Dashboard title: #{config.dashboard_title}"
57
+ puts " Kamal enabled: #{config.kamal_enabled}"
58
+ puts ""
59
+ end
60
+ end