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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: f16e8da81b6991459274f9b8833de22ec79efb5fa2766ebf81e63b6e1546d578
|
|
4
|
+
data.tar.gz: 3af436892b6ee8a664710e22b28d9e2a58790462f9861cff8a2ae0cc0dea69be
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 0a6a38c16936cd01883a74ed208e163faec3829f19fbf5f01d7c0b003efc8e5bfca4e015236ce0aec7253cb912822f26c28d77b32fb1e92b69b49bdbba09e74d
|
|
7
|
+
data.tar.gz: c754879f16a6706abfabfd59dc7b9ad89f06713047f375a9912cb32d3abfaab91a7673b29cd2e110d53e854d2d8f869f3f66bb28adb3dcac83052661feac7aac
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright Micah Geisel
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Trainspotter
|
|
2
|
+
|
|
3
|
+
A zero-config, web-based Rails log viewer with request grouping and session tracking. See your Rails logs in a beautiful, organized interface right in your browser.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Zero configuration** - Just mount the engine and go
|
|
8
|
+
- **Request grouping** - See HTTP requests with their associated SQL queries and view renders
|
|
9
|
+
- **Session tracking** - Group requests by user session with automatic login/logout detection
|
|
10
|
+
- **Real-time updates** - New requests appear automatically via polling
|
|
11
|
+
- **Background processing** - Log ingestion runs in a background job
|
|
12
|
+
- **SQLite storage** - Separate database for fast queries without impacting your app
|
|
13
|
+
- **Dark/light mode** - Respects your system preference
|
|
14
|
+
- **Performance at a glance** - See request duration, query count, and render count
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
Add this line to your application's Gemfile:
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
gem "trainspotter"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
And then execute:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
bundle install
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
Mount the engine in your `config/routes.rb`:
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
Rails.application.routes.draw do
|
|
36
|
+
mount Trainspotter::Engine => "/admin/logs"
|
|
37
|
+
|
|
38
|
+
# Your other routes...
|
|
39
|
+
end
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
That's it! Visit `/trainspotter` in your browser to see your logs.
|
|
43
|
+
|
|
44
|
+
### Restricting Access
|
|
45
|
+
|
|
46
|
+
In production, you'll likely want to restrict access. Here are some options:
|
|
47
|
+
|
|
48
|
+
**With Devise:**
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
authenticate :user, ->(u) { u.admin? } do
|
|
52
|
+
mount Trainspotter::Engine => "/trainspotter"
|
|
53
|
+
end
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
**With HTTP Basic Auth:**
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
mount Trainspotter::Engine => "/trainspotter", constraints: ->(req) {
|
|
60
|
+
Rack::Auth::Basic::Request.new(req.env).provided? &&
|
|
61
|
+
Rack::Auth::Basic::Request.new(req.env).credentials == ["admin", ENV["TRAINSPOTTER_PASSWORD"]]
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**Development only:**
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
if Rails.env.development?
|
|
69
|
+
mount Trainspotter::Engine => "/trainspotter"
|
|
70
|
+
end
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## How It Works
|
|
74
|
+
|
|
75
|
+
Trainspotter reads your Rails log files and parses the standard Rails log format. A background job (`IngestJob`) processes new log entries and stores them in a separate SQLite database.
|
|
76
|
+
|
|
77
|
+
**Requests View:**
|
|
78
|
+
- HTTP method and path
|
|
79
|
+
- Controller and action
|
|
80
|
+
- Response status (color-coded)
|
|
81
|
+
- Total duration
|
|
82
|
+
- Number of SQL queries and view renders
|
|
83
|
+
- Click to expand and see SQL queries and renders
|
|
84
|
+
|
|
85
|
+
**Sessions View:**
|
|
86
|
+
- Groups requests by user session (based on IP + time window)
|
|
87
|
+
- Automatic login/logout detection
|
|
88
|
+
- See all requests made during a session
|
|
89
|
+
|
|
90
|
+
Trainspotter supports tagged logging (`config.log_tags = [:request_id]`) for accurate request grouping in multi-threaded environments.
|
|
91
|
+
|
|
92
|
+
## Requirements
|
|
93
|
+
|
|
94
|
+
- Rails 8.0+
|
|
95
|
+
- Ruby 3.3+
|
|
96
|
+
|
|
97
|
+
## Contributing
|
|
98
|
+
|
|
99
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/botandrose/trainspotter.
|
|
100
|
+
|
|
101
|
+
## License
|
|
102
|
+
|
|
103
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
module Trainspotter
|
|
2
|
+
class RequestsController < ApplicationController
|
|
3
|
+
def index
|
|
4
|
+
@current_log_file = params[:log_file] || Trainspotter.default_log_file
|
|
5
|
+
@current_ip = params[:ip].presence
|
|
6
|
+
@available_log_files = Trainspotter.available_log_files
|
|
7
|
+
|
|
8
|
+
all_requests = filter_requests(RequestRecord.recent(log_file: @current_log_file))
|
|
9
|
+
@available_ips = RequestRecord.unique_ips(log_file: @current_log_file)
|
|
10
|
+
@requests = filter_by_ip(all_requests).first(50)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def poll
|
|
14
|
+
log_file = params[:log_file] || Trainspotter.default_log_file
|
|
15
|
+
ip_filter = params[:ip].presence
|
|
16
|
+
since_id = params[:since_id].presence
|
|
17
|
+
|
|
18
|
+
new_requests = filter_by_ip(
|
|
19
|
+
filter_requests(RequestRecord.poll_for_changes(log_file: log_file, since_id: since_id)),
|
|
20
|
+
ip_filter
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
render json: {
|
|
24
|
+
requests: new_requests.map { |r| render_request_html(r) },
|
|
25
|
+
since_id: new_requests.last&.id
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def render_request_html(request)
|
|
32
|
+
render_to_string(partial: "trainspotter/requests/request", locals: { request: request })
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def filter_requests(requests)
|
|
36
|
+
requests.reject do |request|
|
|
37
|
+
Trainspotter.filter_request?(request.path) || Trainspotter.internal_request?(request)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def filter_by_ip(requests, ip = @current_ip)
|
|
42
|
+
return requests if ip.blank?
|
|
43
|
+
requests.select { |request| request.ip == ip }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module Trainspotter
|
|
2
|
+
class SessionsController < ApplicationController
|
|
3
|
+
def index
|
|
4
|
+
@current_log_file = params[:log_file] || Trainspotter.default_log_file
|
|
5
|
+
@available_log_files = Trainspotter.available_log_files
|
|
6
|
+
@show_anonymous = params[:show_anonymous] == "1"
|
|
7
|
+
|
|
8
|
+
@sessions = SessionRecord.recent(
|
|
9
|
+
log_file: @current_log_file,
|
|
10
|
+
include_anonymous: @show_anonymous,
|
|
11
|
+
limit: 50
|
|
12
|
+
)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def requests
|
|
16
|
+
requests = RequestRecord.for_session(params[:id], limit: 200)
|
|
17
|
+
@requests = filter_requests(requests)
|
|
18
|
+
|
|
19
|
+
render json: {
|
|
20
|
+
requests: @requests.map { |r| render_to_string(partial: "trainspotter/requests/request", locals: { request: r }) }
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def filter_requests(requests)
|
|
27
|
+
requests.reject { |r| Trainspotter.filter_request?(r.path) || Trainspotter.internal_request?(r) }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { Application } from "@hotwired/stimulus"
|
|
2
|
+
import RequestsController from "trainspotter/controllers/requests_controller"
|
|
3
|
+
import SessionsController from "trainspotter/controllers/sessions_controller"
|
|
4
|
+
|
|
5
|
+
const application = Application.start()
|
|
6
|
+
application.register("requests", RequestsController)
|
|
7
|
+
application.register("sessions", SessionsController)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static targets = ["list", "status", "logFile", "ipFilter", "autoScroll"]
|
|
5
|
+
static values = {
|
|
6
|
+
pollUrl: String,
|
|
7
|
+
rootUrl: String,
|
|
8
|
+
sinceId: String,
|
|
9
|
+
logFile: String,
|
|
10
|
+
ip: String
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
connect() {
|
|
14
|
+
this.scrollToBottom()
|
|
15
|
+
this.poll()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
poll() {
|
|
19
|
+
const params = new URLSearchParams()
|
|
20
|
+
params.set("log_file", this.logFileValue)
|
|
21
|
+
if (this.sinceIdValue) params.set("since_id", this.sinceIdValue)
|
|
22
|
+
if (this.ipValue) params.set("ip", this.ipValue)
|
|
23
|
+
|
|
24
|
+
fetch(`${this.pollUrlValue}?${params}`)
|
|
25
|
+
.then(response => response.json())
|
|
26
|
+
.then(data => {
|
|
27
|
+
if (data.requests?.length > 0) {
|
|
28
|
+
data.requests.forEach(html => {
|
|
29
|
+
this.listTarget.insertAdjacentHTML("beforeend", html)
|
|
30
|
+
})
|
|
31
|
+
this.scrollToBottom()
|
|
32
|
+
this.element.querySelector(".empty-state")?.remove()
|
|
33
|
+
}
|
|
34
|
+
if (data.since_id) this.sinceIdValue = data.since_id
|
|
35
|
+
this.statusTarget.classList.remove("disconnected")
|
|
36
|
+
this.statusTarget.classList.add("connected")
|
|
37
|
+
})
|
|
38
|
+
.catch(() => {
|
|
39
|
+
this.statusTarget.classList.remove("connected")
|
|
40
|
+
this.statusTarget.classList.add("disconnected")
|
|
41
|
+
})
|
|
42
|
+
.finally(() => setTimeout(() => this.poll(), 1000))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
scrollToBottom() {
|
|
46
|
+
if (this.autoScrollTarget.checked) {
|
|
47
|
+
this.listTarget.scrollTop = this.listTarget.scrollHeight
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
changeLogFile() {
|
|
52
|
+
this.logFileValue = this.logFileTarget.value
|
|
53
|
+
window.location.href = this.buildUrl()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
changeIp() {
|
|
57
|
+
this.ipValue = this.ipFilterTarget.value
|
|
58
|
+
window.location.href = this.buildUrl()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
buildUrl() {
|
|
62
|
+
const params = new URLSearchParams()
|
|
63
|
+
params.set("log_file", this.logFileValue)
|
|
64
|
+
if (this.ipValue) params.set("ip", this.ipValue)
|
|
65
|
+
return `${this.rootUrlValue}?${params}`
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static targets = ["logFile", "showAnonymous"]
|
|
5
|
+
static values = { sessionsUrl: String }
|
|
6
|
+
|
|
7
|
+
changeLogFile() {
|
|
8
|
+
window.location.href = this.buildUrl()
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
changeShowAnonymous() {
|
|
12
|
+
window.location.href = this.buildUrl()
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
buildUrl() {
|
|
16
|
+
const params = new URLSearchParams()
|
|
17
|
+
if (this.hasLogFileTarget) params.set("log_file", this.logFileTarget.value)
|
|
18
|
+
if (this.hasShowAnonymousTarget && this.showAnonymousTarget.checked) {
|
|
19
|
+
params.set("show_anonymous", "1")
|
|
20
|
+
}
|
|
21
|
+
return `${this.sessionsUrlValue}?${params}`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
loadSession(event) {
|
|
25
|
+
const details = event.currentTarget
|
|
26
|
+
if (!details.open || details.dataset.loaded) return
|
|
27
|
+
|
|
28
|
+
details.dataset.loaded = "true"
|
|
29
|
+
const sessionId = details.dataset.sessionId
|
|
30
|
+
const content = details.querySelector(".session-requests")
|
|
31
|
+
|
|
32
|
+
fetch(`${this.sessionsUrlValue}/${sessionId}/requests`)
|
|
33
|
+
.then(response => response.json())
|
|
34
|
+
.then(data => {
|
|
35
|
+
content.innerHTML = data.requests?.length > 0
|
|
36
|
+
? data.requests.join("")
|
|
37
|
+
: '<p class="empty-hint">No requests in this session.</p>'
|
|
38
|
+
})
|
|
39
|
+
.catch(() => {
|
|
40
|
+
content.innerHTML = '<p class="error-hint">Failed to load requests.</p>'
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
}
|