trainspotter 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f16e8da81b6991459274f9b8833de22ec79efb5fa2766ebf81e63b6e1546d578
4
- data.tar.gz: 3af436892b6ee8a664710e22b28d9e2a58790462f9861cff8a2ae0cc0dea69be
3
+ metadata.gz: aa4ae9c5438445126c81de98e2cf663b64382f8cb867e04e772dcc23ac38e8b5
4
+ data.tar.gz: 07327d4432eca139ce38295901e6bbf81e1e790bcede56aa52eb9f32c4210e1c
5
5
  SHA512:
6
- metadata.gz: 0a6a38c16936cd01883a74ed208e163faec3829f19fbf5f01d7c0b003efc8e5bfca4e015236ce0aec7253cb912822f26c28d77b32fb1e92b69b49bdbba09e74d
7
- data.tar.gz: c754879f16a6706abfabfd59dc7b9ad89f06713047f375a9912cb32d3abfaab91a7673b29cd2e110d53e854d2d8f869f3f66bb28adb3dcac83052661feac7aac
6
+ metadata.gz: 0e44fbe117b303aab6bbba57ea65a269be8157d2321def16c58ff682bf61240e5eb5a518771c438ab3baeda52d1ac6d3c2995393f281db4d476ae9e98b0b8cb3
7
+ data.tar.gz: 100a4af5b00ee54ea1bb660cc244dfbab1faaaf4b271f1bc804c3469d8f9c62838a259dd348e9974cd57e09f71b6172ef2b09c46878de1b000e0b91875b18255
@@ -0,0 +1,25 @@
1
+ import * as Turbo from "@hotwired/turbo"
2
+
3
+ window.trainspotterAutoScroll = true
4
+
5
+ document.addEventListener("turbo:before-stream-render", (event) => {
6
+ document.getElementById("connection-status")?.classList.add("connected")
7
+ document.getElementById("connection-status")?.classList.remove("disconnected")
8
+
9
+ if (window.trainspotterAutoScroll && event.target.action === "append") {
10
+ requestAnimationFrame(() => {
11
+ const list = document.getElementById("request-list")
12
+ if (list) list.scrollTop = list.scrollHeight
13
+ })
14
+ }
15
+ })
16
+
17
+ document.addEventListener("turbo:load", () => {
18
+ const source = document.querySelector("turbo-stream-source")
19
+ if (source) {
20
+ source.addEventListener("error", () => {
21
+ document.getElementById("connection-status")?.classList.remove("connected")
22
+ document.getElementById("connection-status")?.classList.add("disconnected")
23
+ })
24
+ }
25
+ })
@@ -1,5 +1,7 @@
1
1
  module Trainspotter
2
2
  class RequestsController < ApplicationController
3
+ include ActionController::Live
4
+
3
5
  def index
4
6
  @current_log_file = params[:log_file] || Trainspotter.default_log_file
5
7
  @current_ip = params[:ip].presence
@@ -8,28 +10,50 @@ module Trainspotter
8
10
  all_requests = filter_requests(RequestRecord.recent(log_file: @current_log_file))
9
11
  @available_ips = RequestRecord.unique_ips(log_file: @current_log_file)
10
12
  @requests = filter_by_ip(all_requests).first(50)
13
+ @last_id = @requests.first&.id
11
14
  end
12
15
 
13
- def poll
16
+ def stream
17
+ response.headers["Content-Type"] = "text/event-stream"
18
+ response.headers["Cache-Control"] = "no-cache"
19
+ response.headers["X-Accel-Buffering"] = "no"
20
+
14
21
  log_file = params[:log_file] || Trainspotter.default_log_file
15
22
  ip_filter = params[:ip].presence
16
- since_id = params[:since_id].presence
23
+ last_id = params[:since_id].presence
24
+
25
+ sse = SSE.new(response.stream, event: "message")
17
26
 
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
- )
27
+ loop do
28
+ new_requests = filter_by_ip(
29
+ filter_requests(RequestRecord.poll_for_changes(log_file: log_file, since_id: last_id)),
30
+ ip_filter
31
+ )
22
32
 
23
- render json: {
24
- requests: new_requests.map { |r| render_request_html(r) },
25
- since_id: new_requests.last&.id
26
- }
33
+ new_requests.each do |request|
34
+ html = render_request_turbo_stream(request)
35
+ sse.write(html)
36
+ last_id = request.id
37
+ end
38
+
39
+ sleep 1
40
+ end
41
+ rescue IOError, ActionController::Live::ClientDisconnected
42
+ # Client disconnected - this is normal
43
+ ensure
44
+ sse.close
27
45
  end
28
46
 
29
47
  private
30
48
 
31
- def render_request_html(request)
32
- render_to_string(partial: "trainspotter/requests/request", locals: { request: request })
49
+ def render_request_turbo_stream(request)
50
+ html = render_to_string(partial: "trainspotter/requests/request", locals: { request: request })
51
+ <<~TURBO
52
+ <turbo-stream action="append" target="request-list">
53
+ <template>#{html}</template>
54
+ </turbo-stream>
55
+ <turbo-stream action="remove" target="empty-state"></turbo-stream>
56
+ TURBO
33
57
  end
34
58
 
35
59
  def filter_requests(requests)
@@ -13,12 +13,12 @@ module Trainspotter
13
13
  end
14
14
 
15
15
  def requests
16
+ session_record = SessionRecord.find(params[:id])
16
17
  requests = RequestRecord.for_session(params[:id], limit: 200)
17
18
  @requests = filter_requests(requests)
18
19
 
19
- render json: {
20
- requests: @requests.map { |r| render_to_string(partial: "trainspotter/requests/request", locals: { request: r }) }
21
- }
20
+ render partial: "trainspotter/sessions/requests_frame",
21
+ locals: { session: session_record, requests: @requests }
22
22
  end
23
23
 
24
24
  private
@@ -1,7 +1,5 @@
1
1
  module Trainspotter
2
2
  module ApplicationHelper
3
- include Trainspotter.isolated_assets_helper
4
-
5
3
  def ansi_to_html(text)
6
4
  AnsiToHtml.convert(text)
7
5
  end
@@ -7,9 +7,9 @@
7
7
 
8
8
  <%= yield :head %>
9
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"
10
+ <%= Trainspotter.stylesheet_link_tag "application" %>
11
+ <%= Trainspotter.javascript_importmap_tags "application", {
12
+ "@hotwired/turbo" => "https://cdn.jsdelivr.net/npm/@hotwired/turbo@8/+esm"
13
13
  } %>
14
14
  </head>
15
15
  <body>
@@ -1,49 +1,44 @@
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 %>">
1
+ <div class="trainspotter">
8
2
  <header class="trainspotter-header">
9
3
  <h1>Trainspotter</h1>
10
4
  <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>
5
+ <%= form_with url: requests_path, method: :get, data: { turbo_action: "replace" } do %>
6
+ <% if @available_log_files.size > 1 %>
7
+ <%= select_tag :log_file,
8
+ options_for_select(@available_log_files, @current_log_file),
9
+ class: "log-file-selector",
10
+ onchange: "this.form.requestSubmit()" %>
11
+ <% else %>
12
+ <span class="current-log-file"><%= @current_log_file %></span>
24
13
  <% end %>
25
- </select>
14
+ <%= select_tag :ip,
15
+ options_for_select([["All IPs", ""]] + @available_ips.map { |ip| [ip, ip] }, @current_ip),
16
+ class: "ip-filter-selector",
17
+ onchange: "this.form.requestSubmit()" %>
18
+ <% end %>
26
19
  <label class="auto-scroll-toggle">
27
- <input type="checkbox" checked data-requests-target="autoScroll">
20
+ <input type="checkbox" checked id="auto-scroll" onchange="window.trainspotterAutoScroll = this.checked">
28
21
  Auto-scroll
29
22
  </label>
30
- <span class="connection-status" data-requests-target="status">●</span>
23
+ <span class="connection-status" id="connection-status">●</span>
31
24
  <%= link_to "Sessions", sessions_path, class: "nav-link" %>
32
25
  </div>
33
26
  </header>
34
27
 
35
28
  <main class="trainspotter-main">
36
- <div class="request-list" data-requests-target="list">
29
+ <div class="request-list" id="request-list">
37
30
  <% @requests.each do |request| %>
38
31
  <%= render "trainspotter/requests/request", request: request %>
39
32
  <% end %>
40
33
  </div>
41
34
 
42
35
  <% if @requests.empty? %>
43
- <div class="empty-state">
36
+ <div class="empty-state" id="empty-state">
44
37
  <p>No requests found in the log file.</p>
45
38
  <p class="hint">Make some requests to your Rails app to see them here.</p>
46
39
  </div>
47
40
  <% end %>
48
41
  </main>
49
42
  </div>
43
+
44
+ <turbo-stream-source src="<%= stream_requests_path(log_file: @current_log_file, ip: @current_ip, since_id: @last_id) %>"></turbo-stream-source>
@@ -0,0 +1,11 @@
1
+ <turbo-frame id="session_<%= session.id %>_requests">
2
+ <div class="session-requests">
3
+ <% if requests.empty? %>
4
+ <p class="empty-hint">No requests in this session.</p>
5
+ <% else %>
6
+ <% requests.each do |request| %>
7
+ <%= render "trainspotter/requests/request", request: request %>
8
+ <% end %>
9
+ <% end %>
10
+ </div>
11
+ </turbo-frame>
@@ -1,4 +1,4 @@
1
- <details class="session-group <%= session.ongoing? ? 'ongoing' : 'ended' %>" data-session-id="<%= session.id %>" data-action="toggle->sessions#loadSession">
1
+ <details class="session-group <%= session.ongoing? ? 'ongoing' : 'ended' %>">
2
2
  <summary class="session-summary">
3
3
  <span class="session-identity">
4
4
  <% if session.anonymous? %>
@@ -22,7 +22,9 @@
22
22
  </time>
23
23
  </summary>
24
24
 
25
- <div class="session-requests">
26
- <p class="loading-hint">Loading requests...</p>
27
- </div>
25
+ <turbo-frame id="session_<%= session.id %>_requests" src="<%= requests_session_path(session) %>">
26
+ <div class="session-requests">
27
+ <p class="loading-hint">Loading requests...</p>
28
+ </div>
29
+ </turbo-frame>
28
30
  </details>
@@ -1,22 +1,22 @@
1
- <div class="trainspotter"
2
- data-controller="sessions"
3
- data-sessions-sessions-url-value="<%= sessions_path %>">
1
+ <div class="trainspotter">
4
2
  <header class="trainspotter-header">
5
3
  <h1>Trainspotter - Sessions</h1>
6
4
  <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>
5
+ <%= form_with url: sessions_path, method: :get, data: { turbo_action: "replace" } do %>
6
+ <% if @available_log_files.size > 1 %>
7
+ <%= select_tag :log_file,
8
+ options_for_select(@available_log_files, @current_log_file),
9
+ class: "log-file-selector",
10
+ onchange: "this.form.requestSubmit()" %>
11
+ <% else %>
12
+ <span class="current-log-file"><%= @current_log_file %></span>
13
+ <% end %>
14
+ <label class="show-anonymous-toggle">
15
+ <%= check_box_tag :show_anonymous, "1", @show_anonymous,
16
+ onchange: "this.form.requestSubmit()" %>
17
+ Show anonymous
18
+ </label>
15
19
  <% 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
20
  <%= link_to "Requests", requests_path, class: "nav-link" %>
21
21
  </div>
22
22
  </header>
data/config/routes.rb CHANGED
@@ -3,7 +3,7 @@ Trainspotter::Engine.routes.draw do
3
3
 
4
4
  resources :requests, only: [:index] do
5
5
  collection do
6
- get :poll
6
+ get :stream
7
7
  end
8
8
  end
9
9
 
@@ -1,3 +1,3 @@
1
1
  module Trainspotter
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: trainspotter
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Micah Geisel
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '8.0'
18
+ version: '7.2'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: '8.0'
25
+ version: '7.2'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: sqlite3
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -69,16 +69,16 @@ dependencies:
69
69
  name: isolate_assets
70
70
  requirement: !ruby/object:Gem::Requirement
71
71
  requirements:
72
- - - ">="
72
+ - - "~>"
73
73
  - !ruby/object:Gem::Version
74
- version: '0'
74
+ version: 0.3.0
75
75
  type: :runtime
76
76
  prerelease: false
77
77
  version_requirements: !ruby/object:Gem::Requirement
78
78
  requirements:
79
- - - ">="
79
+ - - "~>"
80
80
  - !ruby/object:Gem::Version
81
- version: '0'
81
+ version: 0.3.0
82
82
  description: A mountable Rails engine that provides a beautiful web interface for
83
83
  viewing and understanding your Rails logs. Groups requests with their SQL queries
84
84
  and view renders, with real-time updates.
@@ -91,13 +91,11 @@ files:
91
91
  - MIT-LICENSE
92
92
  - README.md
93
93
  - Rakefile
94
+ - app/assets/javascripts/application.js
95
+ - app/assets/stylesheets/application.css
94
96
  - app/controllers/trainspotter/application_controller.rb
95
97
  - app/controllers/trainspotter/requests_controller.rb
96
98
  - app/controllers/trainspotter/sessions_controller.rb
97
- - app/engine_assets/javascripts/application.js
98
- - app/engine_assets/javascripts/controllers/requests_controller.js
99
- - app/engine_assets/javascripts/controllers/sessions_controller.js
100
- - app/engine_assets/stylesheets/application.css
101
99
  - app/helpers/trainspotter/ansi_to_html.rb
102
100
  - app/helpers/trainspotter/application_helper.rb
103
101
  - app/jobs/trainspotter/ingest/line.rb
@@ -115,6 +113,7 @@ files:
115
113
  - app/views/layouts/trainspotter/application.html.erb
116
114
  - app/views/trainspotter/requests/_request.html.erb
117
115
  - app/views/trainspotter/requests/index.html.erb
116
+ - app/views/trainspotter/sessions/_requests_frame.html.erb
118
117
  - app/views/trainspotter/sessions/_session.html.erb
119
118
  - app/views/trainspotter/sessions/index.html.erb
120
119
  - config/cucumber.yml
@@ -137,7 +136,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
137
136
  requirements:
138
137
  - - ">="
139
138
  - !ruby/object:Gem::Version
140
- version: 3.3.0
139
+ version: 3.2.0
141
140
  required_rubygems_version: !ruby/object:Gem::Requirement
142
141
  requirements:
143
142
  - - ">="
@@ -1,7 +0,0 @@
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)
@@ -1,67 +0,0 @@
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
- }
@@ -1,43 +0,0 @@
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
- }