trainspotter 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 (38) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +103 -0
  4. data/Rakefile +11 -0
  5. data/app/controllers/trainspotter/application_controller.rb +11 -0
  6. data/app/controllers/trainspotter/requests_controller.rb +46 -0
  7. data/app/controllers/trainspotter/sessions_controller.rb +30 -0
  8. data/app/engine_assets/javascripts/application.js +7 -0
  9. data/app/engine_assets/javascripts/controllers/requests_controller.js +67 -0
  10. data/app/engine_assets/javascripts/controllers/sessions_controller.js +43 -0
  11. data/app/engine_assets/stylesheets/application.css +549 -0
  12. data/app/helpers/trainspotter/ansi_to_html.rb +72 -0
  13. data/app/helpers/trainspotter/application_helper.rb +9 -0
  14. data/app/jobs/trainspotter/ingest/line.rb +44 -0
  15. data/app/jobs/trainspotter/ingest/params_parser.rb +36 -0
  16. data/app/jobs/trainspotter/ingest/parser.rb +194 -0
  17. data/app/jobs/trainspotter/ingest/processor.rb +70 -0
  18. data/app/jobs/trainspotter/ingest/reader.rb +84 -0
  19. data/app/jobs/trainspotter/ingest/session_builder.rb +52 -0
  20. data/app/jobs/trainspotter/ingest_job.rb +10 -0
  21. data/app/models/trainspotter/file_position_record.rb +17 -0
  22. data/app/models/trainspotter/record.rb +103 -0
  23. data/app/models/trainspotter/request.rb +108 -0
  24. data/app/models/trainspotter/request_record.rb +133 -0
  25. data/app/models/trainspotter/session_record.rb +71 -0
  26. data/app/views/layouts/trainspotter/application.html.erb +20 -0
  27. data/app/views/trainspotter/requests/_request.html.erb +51 -0
  28. data/app/views/trainspotter/requests/index.html.erb +49 -0
  29. data/app/views/trainspotter/sessions/_session.html.erb +28 -0
  30. data/app/views/trainspotter/sessions/index.html.erb +42 -0
  31. data/config/cucumber.yml +8 -0
  32. data/config/routes.rb +15 -0
  33. data/lib/trainspotter/background_worker.rb +74 -0
  34. data/lib/trainspotter/configuration.rb +68 -0
  35. data/lib/trainspotter/engine.rb +45 -0
  36. data/lib/trainspotter/version.rb +3 -0
  37. data/lib/trainspotter.rb +30 -0
  38. metadata +150 -0
@@ -0,0 +1,133 @@
1
+ module Trainspotter
2
+ class RequestRecord < Record
3
+ self.table_name = "requests"
4
+
5
+ belongs_to :session_record,
6
+ class_name: "Trainspotter::SessionRecord",
7
+ foreign_key: "session_id",
8
+ optional: true
9
+
10
+ scope :completed, -> { where(completed: true) }
11
+ scope :for_log_file, ->(log_file) { where(log_file: log_file) }
12
+
13
+ scope :recent, ->(log_file:, limit: nil) {
14
+ limit ||= Trainspotter.recent_request_limit
15
+ for_log_file(log_file).completed.order(started_at: :desc).limit(limit)
16
+ }
17
+
18
+ scope :since, ->(since_id) {
19
+ return all unless since_id
20
+
21
+ reference = find_by(id: since_id)
22
+ return none unless reference
23
+
24
+ where("created_at > ?", reference.created_at)
25
+ }
26
+
27
+ def self.poll_for_changes(log_file:, since_id: nil)
28
+ for_log_file(log_file)
29
+ .completed
30
+ .since(since_id)
31
+ .order(started_at: :asc)
32
+ end
33
+
34
+ def self.unique_ips(log_file:)
35
+ for_log_file(log_file)
36
+ .where.not(ip: nil)
37
+ .distinct
38
+ .pluck(:ip)
39
+ .sort
40
+ end
41
+
42
+ def self.upsert_from_request(log_file, request)
43
+ entries_data = request.entries.map do |entry|
44
+ {
45
+ raw: entry.raw,
46
+ type: entry.type,
47
+ timestamp: entry.timestamp&.iso8601,
48
+ metadata: entry.metadata
49
+ }
50
+ end
51
+
52
+ upsert(
53
+ {
54
+ log_request_id: request.id,
55
+ log_file: log_file,
56
+ method: request.method,
57
+ path: request.path,
58
+ status: request.status,
59
+ duration_ms: request.duration_ms,
60
+ ip: request.ip,
61
+ controller: request.controller,
62
+ action: request.action,
63
+ started_at: request.started_at,
64
+ entries_json: entries_data,
65
+ completed: request.completed?
66
+ },
67
+ unique_by: :log_request_id
68
+ )
69
+ end
70
+
71
+ def self.for_session(session_id, limit: 100)
72
+ where(session_id: session_id)
73
+ .completed
74
+ .order(started_at: :asc)
75
+ .limit(limit)
76
+ end
77
+
78
+ serialize :entries_json, coder: JSON
79
+
80
+ def entries
81
+ parsed_entries
82
+ end
83
+
84
+ def sql_entries
85
+ parsed_entries.select(&:sql?)
86
+ end
87
+
88
+ def render_entries
89
+ parsed_entries.select(&:render?)
90
+ end
91
+
92
+ private def parsed_entries
93
+ @parsed_entries ||= (entries_json || []).map do |data|
94
+ Ingest::Line.new(
95
+ raw: data["raw"],
96
+ type: data["type"]&.to_sym || :other,
97
+ timestamp: data["timestamp"] ? Time.parse(data["timestamp"]) : nil,
98
+ metadata: (data["metadata"] || {}).transform_keys(&:to_sym)
99
+ )
100
+ end
101
+ end
102
+
103
+ def sql_count
104
+ sql_entries.size
105
+ end
106
+
107
+ def sql_duration_ms
108
+ sql_entries.sum { |e| e.duration_ms || 0 }
109
+ end
110
+
111
+ def render_count
112
+ render_entries.size
113
+ end
114
+
115
+ def render_duration_ms
116
+ render_entries.sum { |e| e.duration_ms || 0 }
117
+ end
118
+
119
+ def status_class
120
+ case status
121
+ when 200..299 then "success"
122
+ when 300..399 then "redirect"
123
+ when 400..499 then "client-error"
124
+ when 500..599 then "server-error"
125
+ else "unknown"
126
+ end
127
+ end
128
+
129
+ def completed?
130
+ completed
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,71 @@
1
+ module Trainspotter
2
+ class SessionRecord < Record
3
+ self.table_name = "sessions"
4
+
5
+ has_many :request_records,
6
+ class_name: "Trainspotter::RequestRecord",
7
+ foreign_key: "session_id"
8
+
9
+ scope :for_log_file, ->(log_file) { where(log_file: log_file) }
10
+ scope :identified, -> { where.not(email: nil) }
11
+ scope :ongoing, -> { where(end_reason: "ongoing") }
12
+
13
+ scope :recent, ->(log_file:, include_anonymous: false, limit: 50) {
14
+ scope = for_log_file(log_file).order(started_at: :desc).limit(limit)
15
+ include_anonymous ? scope : scope.identified
16
+ }
17
+
18
+ def self.find_active(ip:, after:, log_file:)
19
+ where(ip: ip, log_file: log_file, end_reason: "ongoing")
20
+ .where("started_at > ?", after)
21
+ .order(started_at: :desc)
22
+ .first
23
+ end
24
+
25
+ def self.expire_before(cutoff, log_file:)
26
+ ongoing
27
+ .for_log_file(log_file)
28
+ .where("ended_at < ?", cutoff)
29
+ .update_all(end_reason: "timeout")
30
+ end
31
+
32
+ attribute :id, :string, default: -> { SecureRandom.hex(8) }
33
+
34
+ def anonymous?
35
+ email.nil?
36
+ end
37
+
38
+ def ongoing?
39
+ end_reason == "ongoing"
40
+ end
41
+
42
+ def time_range_display
43
+ return "Unknown" unless started_at
44
+ start_str = started_at.strftime("%b %d %H:%M")
45
+ end_str = ended_at&.strftime("%H:%M") || "now"
46
+ "#{start_str} - #{end_str}"
47
+ end
48
+
49
+ def duration_seconds
50
+ return nil unless started_at
51
+ end_time = ended_at || Time.current
52
+ (end_time - started_at).to_i
53
+ end
54
+
55
+ def duration_display
56
+ seconds = duration_seconds
57
+ return "Unknown" unless seconds
58
+
59
+ if seconds < 60
60
+ "#{seconds}s"
61
+ elsif seconds < 3600
62
+ minutes = seconds / 60
63
+ "#{minutes}m"
64
+ else
65
+ hours = seconds / 3600
66
+ minutes = (seconds % 3600) / 60
67
+ "#{hours}h #{minutes}m"
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,20 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Trainspotter</title>
5
+ <%= csrf_meta_tags %>
6
+ <%= csp_meta_tag %>
7
+
8
+ <%= yield :head %>
9
+
10
+ <%= engine_stylesheet_link_tag "application" %>
11
+ <%= engine_javascript_importmap_tags "application", {
12
+ "@hotwired/stimulus" => "https://cdn.jsdelivr.net/npm/@hotwired/stimulus@3/+esm"
13
+ } %>
14
+ </head>
15
+ <body>
16
+
17
+ <%= yield %>
18
+
19
+ </body>
20
+ </html>
@@ -0,0 +1,51 @@
1
+ <details class="request-group <%= request.status_class %>" <%= "open" unless request.completed? %>>
2
+ <summary class="request-summary">
3
+ <span class="request-method method-<%= request.method.downcase %>"><%= request.method %></span>
4
+ <span class="request-path"><%= request.path %></span>
5
+ <% if request.controller %>
6
+ <span class="request-controller"><%= request.controller %>#<%= request.action %></span>
7
+ <% end %>
8
+ <span class="request-meta">
9
+ <% if request.status %>
10
+ <span class="request-status status-<%= request.status_class %>"><%= request.status %></span>
11
+ <% end %>
12
+ <% if request.duration_ms %>
13
+ <span class="request-duration"><%= "%.1f" % request.duration_ms %>ms</span>
14
+ <% end %>
15
+ <% if request.sql_count > 0 %>
16
+ <span class="request-sql" title="<%= request.sql_count %> queries, <%= "%.1f" % request.sql_duration_ms %>ms">
17
+ 🗃️ <%= request.sql_count %>
18
+ </span>
19
+ <% end %>
20
+ <% if request.render_count > 0 %>
21
+ <span class="request-renders" title="<%= request.render_count %> templates, <%= "%.1f" % request.render_duration_ms %>ms">
22
+ 📄 <%= request.render_count %>
23
+ </span>
24
+ <% end %>
25
+ </span>
26
+ <% if request.started_at %>
27
+ <time class="request-time" datetime="<%= request.started_at.iso8601 %>">
28
+ <%= request.started_at.strftime("%H:%M:%S") %>
29
+ </time>
30
+ <% end %>
31
+ </summary>
32
+
33
+ <div class="request-details">
34
+ <% request.entries.each do |entry| %>
35
+ <div class="log-entry entry-<%= entry.type %>">
36
+ <% case entry.type %>
37
+ <% when :sql %>
38
+ <span class="entry-badge sql">SQL</span>
39
+ <span class="entry-duration"><%= "%.2f" % entry.duration_ms %>ms</span>
40
+ <code class="entry-content"><%= ansi_to_html(entry.metadata[:query]) %></code>
41
+ <% when :render %>
42
+ <span class="entry-badge render">VIEW</span>
43
+ <span class="entry-duration"><%= "%.2f" % entry.duration_ms %>ms</span>
44
+ <span class="entry-content"><%= ansi_to_html(entry.metadata[:template]) %></span>
45
+ <% else %>
46
+ <pre class="entry-raw"><%= ansi_to_html(entry.raw) %></pre>
47
+ <% end %>
48
+ </div>
49
+ <% end %>
50
+ </div>
51
+ </details>
@@ -0,0 +1,49 @@
1
+ <div class="trainspotter"
2
+ data-controller="requests"
3
+ data-requests-poll-url-value="<%= poll_requests_path %>"
4
+ data-requests-root-url-value="<%= root_path %>"
5
+ data-requests-since-id-value="<%= @requests.first&.id %>"
6
+ data-requests-log-file-value="<%= @current_log_file %>"
7
+ data-requests-ip-value="<%= @current_ip %>">
8
+ <header class="trainspotter-header">
9
+ <h1>Trainspotter</h1>
10
+ <div class="trainspotter-controls">
11
+ <% if @available_log_files.size > 1 %>
12
+ <select name="log_file" class="log-file-selector" data-requests-target="logFile" data-action="change->requests#changeLogFile">
13
+ <% @available_log_files.each do |log_file| %>
14
+ <option value="<%= log_file %>" <%= "selected" if log_file == @current_log_file %>><%= log_file %></option>
15
+ <% end %>
16
+ </select>
17
+ <% else %>
18
+ <span class="current-log-file"><%= @current_log_file %></span>
19
+ <% end %>
20
+ <select name="ip" class="ip-filter-selector" data-requests-target="ipFilter" data-action="change->requests#changeIp">
21
+ <option value="">All IPs</option>
22
+ <% @available_ips.each do |ip| %>
23
+ <option value="<%= ip %>" <%= "selected" if ip == @current_ip %>><%= ip %></option>
24
+ <% end %>
25
+ </select>
26
+ <label class="auto-scroll-toggle">
27
+ <input type="checkbox" checked data-requests-target="autoScroll">
28
+ Auto-scroll
29
+ </label>
30
+ <span class="connection-status" data-requests-target="status">●</span>
31
+ <%= link_to "Sessions", sessions_path, class: "nav-link" %>
32
+ </div>
33
+ </header>
34
+
35
+ <main class="trainspotter-main">
36
+ <div class="request-list" data-requests-target="list">
37
+ <% @requests.each do |request| %>
38
+ <%= render "trainspotter/requests/request", request: request %>
39
+ <% end %>
40
+ </div>
41
+
42
+ <% if @requests.empty? %>
43
+ <div class="empty-state">
44
+ <p>No requests found in the log file.</p>
45
+ <p class="hint">Make some requests to your Rails app to see them here.</p>
46
+ </div>
47
+ <% end %>
48
+ </main>
49
+ </div>
@@ -0,0 +1,28 @@
1
+ <details class="session-group <%= session.ongoing? ? 'ongoing' : 'ended' %>" data-session-id="<%= session.id %>" data-action="toggle->sessions#loadSession">
2
+ <summary class="session-summary">
3
+ <span class="session-identity">
4
+ <% if session.anonymous? %>
5
+ <span class="session-email anonymous">Anonymous</span>
6
+ <% else %>
7
+ <span class="session-email"><%= session.email %></span>
8
+ <% end %>
9
+ </span>
10
+ <span class="session-ip"><%= session.ip %></span>
11
+ <span class="session-meta">
12
+ <span class="session-request-count" title="<%= session.request_count %> requests">
13
+ <%= session.request_count %> req
14
+ </span>
15
+ <span class="session-status <%= session.ongoing? ? 'ongoing' : session.end_reason %>">
16
+ <%= session.ongoing? ? 'Active' : session.end_reason.capitalize %>
17
+ </span>
18
+ <span class="session-duration"><%= session.duration_display %></span>
19
+ </span>
20
+ <time class="session-time" title="<%= session.time_range_display %>">
21
+ <%= session.started_at&.strftime("%H:%M:%S") || "Unknown" %>
22
+ </time>
23
+ </summary>
24
+
25
+ <div class="session-requests">
26
+ <p class="loading-hint">Loading requests...</p>
27
+ </div>
28
+ </details>
@@ -0,0 +1,42 @@
1
+ <div class="trainspotter"
2
+ data-controller="sessions"
3
+ data-sessions-sessions-url-value="<%= sessions_path %>">
4
+ <header class="trainspotter-header">
5
+ <h1>Trainspotter - Sessions</h1>
6
+ <div class="trainspotter-controls">
7
+ <% if @available_log_files.size > 1 %>
8
+ <select name="log_file" class="log-file-selector" data-sessions-target="logFile" data-action="change->sessions#changeLogFile">
9
+ <% @available_log_files.each do |log_file| %>
10
+ <option value="<%= log_file %>" <%= "selected" if log_file == @current_log_file %>><%= log_file %></option>
11
+ <% end %>
12
+ </select>
13
+ <% else %>
14
+ <span class="current-log-file"><%= @current_log_file %></span>
15
+ <% end %>
16
+ <label class="show-anonymous-toggle">
17
+ <input type="checkbox" <%= "checked" if @show_anonymous %> data-sessions-target="showAnonymous" data-action="change->sessions#changeShowAnonymous">
18
+ Show anonymous
19
+ </label>
20
+ <%= link_to "Requests", requests_path, class: "nav-link" %>
21
+ </div>
22
+ </header>
23
+
24
+ <main class="trainspotter-main">
25
+ <div class="session-list">
26
+ <% @sessions.each do |session| %>
27
+ <%= render "trainspotter/sessions/session", session: session %>
28
+ <% end %>
29
+ </div>
30
+
31
+ <% if @sessions.empty? %>
32
+ <div class="empty-state">
33
+ <p>No sessions found in the log file.</p>
34
+ <% if !@show_anonymous %>
35
+ <p class="hint">Try checking "Show anonymous" to see sessions without identified users.</p>
36
+ <% else %>
37
+ <p class="hint">Make some requests to your Rails app to see sessions here.</p>
38
+ <% end %>
39
+ </div>
40
+ <% end %>
41
+ </main>
42
+ </div>
@@ -0,0 +1,8 @@
1
+ <%
2
+ rerun = File.file?('rerun.txt') ? IO.read('rerun.txt') : ""
3
+ rerun = rerun.strip.gsub /\s/, ' '
4
+ rerun_opts = rerun.empty? ? "--format #{ENV['CUCUMBER_FORMAT'] || 'progress'} features" : "--format #{ENV['CUCUMBER_FORMAT'] || 'pretty'} #{rerun}"
5
+ std_opts = "--format #{ENV['CUCUMBER_FORMAT'] || 'pretty'} --strict --tags 'not @wip' --publish-quiet"
6
+ %>
7
+ default: <%= std_opts %> features
8
+ rerun: <%= rerun_opts %> --format rerun --out rerun.txt --strict --tags 'not @wip'
data/config/routes.rb ADDED
@@ -0,0 +1,15 @@
1
+ Trainspotter::Engine.routes.draw do
2
+ root to: "requests#index"
3
+
4
+ resources :requests, only: [:index] do
5
+ collection do
6
+ get :poll
7
+ end
8
+ end
9
+
10
+ resources :sessions, only: [:index] do
11
+ member do
12
+ get :requests
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,74 @@
1
+ require "concurrent"
2
+
3
+ module Trainspotter
4
+ class BackgroundWorker < Struct.new(:interval, :lock_path, :logger, :task_block, :timer_task, :lock_file, keyword_init: true)
5
+ class << self
6
+ attr_accessor :instance
7
+
8
+ def start(interval:, lock_path:, logger: nil, &task_block)
9
+ raise ArgumentError, "a block is required" unless task_block
10
+ return if instance&.running?
11
+
12
+ self.instance = new(interval:, lock_path:, logger:, task_block:)
13
+ instance.start
14
+ end
15
+
16
+ def stop
17
+ instance&.stop
18
+ self.instance = nil
19
+ end
20
+ end
21
+
22
+ def start
23
+ return if running?
24
+ return unless acquire_lock
25
+
26
+ self.timer_task = Concurrent::TimerTask.new(
27
+ execution_interval: interval,
28
+ run_now: true,
29
+ &task_block
30
+ )
31
+
32
+ timer_task.add_observer do |_time, _result, error|
33
+ if error
34
+ logger&.error "[Trainspotter] Background worker error: #{error.message}"
35
+ logger&.error error.backtrace.first(10).join("\n")
36
+ end
37
+ end
38
+
39
+ timer_task.execute
40
+
41
+ logger&.info "[Trainspotter] Background worker started (pid=#{Process.pid})"
42
+ end
43
+
44
+ def stop
45
+ timer_task&.shutdown
46
+ self.timer_task = nil
47
+ release_lock
48
+
49
+ logger&.info "[Trainspotter] Background worker stopped"
50
+ end
51
+
52
+ def running?
53
+ timer_task&.running?
54
+ end
55
+
56
+ private
57
+
58
+ def acquire_lock
59
+ self.lock_file = File.open(lock_path, File::RDWR | File::CREAT)
60
+ lock_file.flock(File::LOCK_EX | File::LOCK_NB)
61
+ rescue Errno::EWOULDBLOCK, Errno::EAGAIN
62
+ lock_file&.close
63
+ self.lock_file = nil
64
+ false
65
+ end
66
+
67
+ def release_lock
68
+ return unless lock_file
69
+ lock_file.flock(File::LOCK_UN)
70
+ lock_file.close
71
+ self.lock_file = nil
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,68 @@
1
+ module Trainspotter
2
+ class Configuration
3
+ class << self
4
+ def setting(name, default: nil, &block)
5
+ attr_writer name
6
+
7
+ ivar = :"@#{name}"
8
+ define_method(name) do
9
+ unless instance_variable_defined?(ivar)
10
+ instance_variable_set(ivar, block ? instance_eval(&block) : default)
11
+ end
12
+ instance_variable_get(ivar)
13
+ end
14
+ end
15
+ end
16
+
17
+ setting(:log_directory) { Rails.root.join("log").to_s }
18
+ setting(:database_path) { Rails.root.join("tmp", "trainspotter.sqlite3").to_s }
19
+ setting(:session_timeout) { 30.minutes }
20
+ setting(:background_worker_interval) { 2.seconds }
21
+ setting :recent_request_limit, default: 100
22
+
23
+ setting(:filtered_paths) do
24
+ [
25
+ %r{^/assets/},
26
+ %r{^/packs/},
27
+ %r{^/vite/},
28
+ %r{^/rails/active_storage/},
29
+ %r{^/cable$},
30
+ %r{\.map$},
31
+ %r{\.hot-update\.}
32
+ ]
33
+ end
34
+
35
+ setting(:login_detectors) do
36
+ {
37
+ session_create: ->(request) {
38
+ return nil unless request.method == "POST" && request.path == "/session"
39
+ request.params&.dig("session", "email")
40
+ }
41
+ }
42
+ end
43
+
44
+ setting(:logout_detectors) do
45
+ {
46
+ session_destroy: ->(request) {
47
+ request.method == "DELETE" && request.path == "/session"
48
+ }
49
+ }
50
+ end
51
+
52
+ def available_log_files
53
+ Dir.glob(File.join(log_directory, "*.log")).map { |f| File.basename(f) }.sort
54
+ end
55
+
56
+ def default_log_file
57
+ "#{Rails.env}.log"
58
+ end
59
+
60
+ def filter_request?(path)
61
+ filtered_paths.any? { |pattern| pattern.match?(path) }
62
+ end
63
+
64
+ def internal_request?(request)
65
+ request.controller&.start_with?("Trainspotter::")
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,45 @@
1
+ module Trainspotter
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace Trainspotter
4
+ isolate_assets
5
+
6
+ initializer "trainspotter.silence_requests" do
7
+ config.after_initialize { Engine.silence_engine_requests }
8
+ end
9
+
10
+ initializer "trainspotter.background_worker" do
11
+ config.after_initialize do
12
+ Engine.start_background_worker unless Rails.env.test?
13
+ end
14
+
15
+ at_exit { Trainspotter::BackgroundWorker.stop }
16
+ end
17
+
18
+ def self.silence_engine_requests
19
+ if mount_path = engine_mount_path
20
+ Rails.application.config.middleware.insert_before(
21
+ Rails::Rack::Logger,
22
+ Rails::Rack::SilenceRequest,
23
+ path: %r{^#{Regexp.escape(mount_path)}}
24
+ )
25
+ end
26
+ end
27
+
28
+ def self.engine_mount_path
29
+ route = Rails.application.routes.routes.find do |r|
30
+ r.app.respond_to?(:app) && r.app.app == Trainspotter::Engine
31
+ end
32
+ route&.path&.spec&.to_s&.chomp("(.:format)")
33
+ end
34
+
35
+ def self.start_background_worker
36
+ Trainspotter::BackgroundWorker.start(
37
+ interval: Trainspotter.background_worker_interval,
38
+ lock_path: Rails.root.join("tmp", "trainspotter.lock"),
39
+ logger: Rails.logger
40
+ ) do
41
+ Trainspotter::IngestJob.new.perform
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,3 @@
1
+ module Trainspotter
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,30 @@
1
+ require "trainspotter/version"
2
+ require "trainspotter/configuration"
3
+ require "isolate_assets"
4
+ require "trainspotter/engine"
5
+ require "trainspotter/background_worker"
6
+
7
+ module Trainspotter
8
+
9
+ class << self
10
+ def configuration
11
+ @configuration ||= Configuration.new
12
+ end
13
+
14
+ def configure
15
+ yield(configuration)
16
+ end
17
+
18
+ def method_missing(method, *args, &block)
19
+ if configuration.respond_to?(method)
20
+ configuration.public_send(method, *args, &block)
21
+ else
22
+ super
23
+ end
24
+ end
25
+
26
+ def respond_to_missing?(method, include_private = false)
27
+ configuration.respond_to?(method) || super
28
+ end
29
+ end
30
+ end