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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +103 -0
- data/Rakefile +11 -0
- data/app/controllers/trainspotter/application_controller.rb +11 -0
- data/app/controllers/trainspotter/requests_controller.rb +46 -0
- data/app/controllers/trainspotter/sessions_controller.rb +30 -0
- data/app/engine_assets/javascripts/application.js +7 -0
- data/app/engine_assets/javascripts/controllers/requests_controller.js +67 -0
- data/app/engine_assets/javascripts/controllers/sessions_controller.js +43 -0
- data/app/engine_assets/stylesheets/application.css +549 -0
- data/app/helpers/trainspotter/ansi_to_html.rb +72 -0
- data/app/helpers/trainspotter/application_helper.rb +9 -0
- data/app/jobs/trainspotter/ingest/line.rb +44 -0
- data/app/jobs/trainspotter/ingest/params_parser.rb +36 -0
- data/app/jobs/trainspotter/ingest/parser.rb +194 -0
- data/app/jobs/trainspotter/ingest/processor.rb +70 -0
- data/app/jobs/trainspotter/ingest/reader.rb +84 -0
- data/app/jobs/trainspotter/ingest/session_builder.rb +52 -0
- data/app/jobs/trainspotter/ingest_job.rb +10 -0
- data/app/models/trainspotter/file_position_record.rb +17 -0
- data/app/models/trainspotter/record.rb +103 -0
- data/app/models/trainspotter/request.rb +108 -0
- data/app/models/trainspotter/request_record.rb +133 -0
- data/app/models/trainspotter/session_record.rb +71 -0
- data/app/views/layouts/trainspotter/application.html.erb +20 -0
- data/app/views/trainspotter/requests/_request.html.erb +51 -0
- data/app/views/trainspotter/requests/index.html.erb +49 -0
- data/app/views/trainspotter/sessions/_session.html.erb +28 -0
- data/app/views/trainspotter/sessions/index.html.erb +42 -0
- data/config/cucumber.yml +8 -0
- data/config/routes.rb +15 -0
- data/lib/trainspotter/background_worker.rb +74 -0
- data/lib/trainspotter/configuration.rb +68 -0
- data/lib/trainspotter/engine.rb +45 -0
- data/lib/trainspotter/version.rb +3 -0
- data/lib/trainspotter.rb +30 -0
- 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>
|
data/config/cucumber.yml
ADDED
|
@@ -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,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
|
data/lib/trainspotter.rb
ADDED
|
@@ -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
|