query_owl 0.6.0 → 0.7.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: 9d6d1c90011594e84ad366b86242261eb507c646bbbd99567ee68e764f79fed3
4
- data.tar.gz: 6b3d04a4eeba7e6cd61f77ac1c86d9267490cb68bc4bee375bf3fca39db21c48
3
+ metadata.gz: daca52db3c3f2564dc0e0e51b2271e995db9042ffd82d2b2a2ef25529fc5cbd4
4
+ data.tar.gz: 47cad0afa15471a0fb60dc6366dbadcc5bc480b2cf7b17321198230fd46f8d8b
5
5
  SHA512:
6
- metadata.gz: 48dd728246c8226b5fc04bf819e85712e8eaf5453d1fe77ca1ce3ba012f7399a52af50cffacb652c5958f2ab086c35270930fed9c093971b3e446bc3846f1756
7
- data.tar.gz: 1f26cb6d3c28dccf5708446b4c9e17ce2f334af66ae8b0ed6c6d92eb36440818b4c1feff3cf4ef124f455e54e050641eadc8952aa0a8d59c5b2a4ed50672a8cf
6
+ metadata.gz: 82cb7e1f4f42c553731c3e0ba32f4b4f7f01a0bf21c837644371fcb02313d4995f27f84fd70f574df3f6ef889266ddec6ba2c7bbf3c5302f3ee0c1084d431539
7
+ data.tar.gz: 8bf2d7078c201e0bb673943194dbb3d98d07e30a1ce6a9c64ac6c72f0416870aca1349a21050779c7edbc92c1d5f2609219d44fed7745591d60ca83dfe98c387
@@ -2,4 +2,5 @@
2
2
  .qo-header h1 { font-size: 1.4rem; font-weight: 600; }
3
3
  .qo-header p { color: var(--muted); margin-top: 0.2rem; }
4
4
 
5
- .qo-empty { color: var(--muted); font-style: italic; margin-top: 1rem; }
5
+ .qo-empty { color: var(--muted); font-style: italic; margin-top: 1rem; }
6
+ .qo-summary { margin-bottom: 0.5rem; }
@@ -31,4 +31,13 @@
31
31
  .qo-table td:nth-child(2) { max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
32
32
 
33
33
  .qo-request { white-space: nowrap; }
34
- .qo-path { font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; font-size: 11px; opacity: .7; }
34
+ .qo-path { font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; font-size: 11px; opacity: .7; }
35
+
36
+ .qo-sort-link {
37
+ color: inherit;
38
+ text-decoration: none;
39
+ display: block;
40
+ }
41
+ .qo-sort-link:hover { color: var(--accent, #4f46e5); }
42
+
43
+ .qo-sort-active { color: var(--accent, #4f46e5); }
@@ -0,0 +1,80 @@
1
+ .qo-filters {
2
+ display: flex;
3
+ align-items: center;
4
+ gap: 0.75rem;
5
+ margin-bottom: 1rem;
6
+ padding: 0.6rem 1rem;
7
+ background: var(--surface);
8
+ border-radius: var(--radius);
9
+ box-shadow: var(--shadow);
10
+ flex-wrap: wrap;
11
+ }
12
+
13
+ .qo-filters label {
14
+ font-size: 11px;
15
+ font-weight: 600;
16
+ text-transform: uppercase;
17
+ letter-spacing: .04em;
18
+ color: var(--muted);
19
+ }
20
+
21
+ .qo-select,
22
+ .qo-input {
23
+ font-family: inherit;
24
+ font-size: 13px;
25
+ padding: 0.3rem 0.6rem;
26
+ border: 1px solid var(--border);
27
+ border-radius: var(--radius);
28
+ background: var(--bg);
29
+ color: var(--text);
30
+ }
31
+
32
+ .qo-input { min-width: 160px; }
33
+
34
+ .qo-input-wrapper {
35
+ position: relative;
36
+ display: inline-flex;
37
+ align-items: center;
38
+ }
39
+
40
+ .qo-input-wrapper .qo-input { padding-right: 1.6rem; }
41
+
42
+ .qo-input-clear {
43
+ position: absolute;
44
+ right: 0.4rem;
45
+ background: none;
46
+ border: none;
47
+ cursor: pointer;
48
+ color: var(--muted);
49
+ font-size: 11px;
50
+ line-height: 1;
51
+ padding: 0.15rem 0.2rem;
52
+ border-radius: 2px;
53
+ }
54
+
55
+ .qo-input-clear:hover { color: var(--text); background: var(--border); }
56
+
57
+ .qo-select:focus,
58
+ .qo-input:focus {
59
+ outline: none;
60
+ border-color: #86b7fe;
61
+ box-shadow: 0 0 0 2px rgba(13,110,253,.15);
62
+ }
63
+
64
+ .qo-btn {
65
+ display: inline-block;
66
+ padding: 0.3rem 0.75rem;
67
+ font-size: 12px;
68
+ border-radius: var(--radius);
69
+ text-decoration: none;
70
+ cursor: pointer;
71
+ font-family: inherit;
72
+ }
73
+
74
+ .qo-btn--muted {
75
+ background: transparent;
76
+ color: var(--muted);
77
+ border: 1px solid var(--border);
78
+ }
79
+
80
+ .qo-btn--muted:hover { background: var(--bg); color: var(--text); }
@@ -6,23 +6,43 @@ module QueryOwl
6
6
 
7
7
  before_action :check_dashboard_enabled, if: -> { request.format.html? }
8
8
 
9
+ SORTABLE_COLUMNS = %w[type info recorded_at].freeze
10
+
9
11
  def index
10
12
  filters = request.query_parameters
11
13
  events = EventStore.all
12
- events = events.select { |e| e[:type].to_s == filters["type"] } if filters["type"].present?
13
- events = events.select { |e| e[:controller] == filters["controller"] } if filters["controller"].present?
14
- events = events.select { |e| e[:action] == filters["action"] } if filters["action"].present?
14
+ events = events.select { |e| e[:type].to_s == filters["type"] } if filters["type"].present?
15
+ events = events.select { |e| e[:controller].to_s.include?(filters["controller"]) } if filters["controller"].present?
16
+ events = events.select { |e| e[:action] == filters["action"] } if filters["action"].present?
15
17
 
16
18
  respond_to do |format|
17
19
  format.json { render json: events }
18
- format.html { @events = events.reverse }
20
+ format.html do
21
+ @type_filter = filters["type"].presence
22
+ @controller_filter = filters["controller"].presence
23
+ @sort = SORTABLE_COLUMNS.include?(filters["sort"]) ? filters["sort"] : "recorded_at"
24
+ @direction = filters["direction"] == "asc" ? "asc" : "desc"
25
+ @events = sorted_events(events, @sort, @direction)
26
+ end
19
27
  end
20
28
  end
21
29
 
22
30
  private
23
31
 
24
- def check_dashboard_enabled
25
- head :forbidden unless QueryOwl.config.dashboard_enabled
26
- end
32
+ def sorted_events(events, sort, direction)
33
+ sorted = case sort
34
+ when "type"
35
+ events.sort_by { |e| e[:type].to_s }
36
+ when "info"
37
+ events.sort_by { |e| e[:duration_ms] || e[:count] || 0 }
38
+ else
39
+ events.sort_by { |e| e[:recorded_at] || Time.at(0) }
40
+ end
41
+ direction == "asc" ? sorted : sorted.reverse
42
+ end
43
+
44
+ def check_dashboard_enabled
45
+ head :forbidden unless QueryOwl.config.dashboard_enabled
46
+ end
27
47
  end
28
48
  end
@@ -5,5 +5,21 @@ module QueryOwl
5
5
  css = dir.glob("_*.css").sort.map(&:read).join("\n")
6
6
  content_tag(:style, css.html_safe)
7
7
  end
8
+
9
+ def sort_th(label, column, current_sort, current_dir)
10
+ active = current_sort == column.to_s
11
+ next_dir = (active && current_dir == "asc") ? "desc" : "asc"
12
+ indicator = active ? (current_dir == "asc" ? " ▲" : " ▼") : ""
13
+ params = request.query_parameters.merge("sort" => column, "direction" => next_dir)
14
+ href = "?" + params.to_query
15
+ content_tag(:th) do
16
+ link_to(
17
+ "#{label}#{indicator}".html_safe,
18
+ href,
19
+ class: ["qo-sort-link", ("qo-sort-active" if active)].compact.join(" "),
20
+ data: { turbo_frame: "qo-events" }
21
+ )
22
+ end
23
+ end
8
24
  end
9
25
  end
@@ -0,0 +1,6 @@
1
+ import "@hotwired/turbo"
2
+ import { Application } from "@hotwired/stimulus"
3
+ import TableFilterController from "query_owl/table_filter_controller"
4
+
5
+ const application = Application.start()
6
+ application.register("table-filter", TableFilterController)
@@ -0,0 +1,32 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["controllerInput", "clearButton"]
5
+
6
+ connect() {
7
+ this._updateClear()
8
+ }
9
+
10
+ filter({ target }) {
11
+ clearTimeout(this._timer)
12
+ this._updateClear()
13
+ this._timer = setTimeout(() => target.form.requestSubmit(), 300)
14
+ }
15
+
16
+ select({ target }) {
17
+ clearTimeout(this._timer)
18
+ target.form.requestSubmit()
19
+ }
20
+
21
+ clearController() {
22
+ this.controllerInputTarget.value = ""
23
+ this._updateClear()
24
+ this.controllerInputTarget.form.requestSubmit()
25
+ }
26
+
27
+ _updateClear() {
28
+ if (this.hasClearButtonTarget) {
29
+ this.clearButtonTarget.hidden = this.controllerInputTarget.value.length === 0
30
+ }
31
+ }
32
+ }
@@ -6,6 +6,7 @@
6
6
  <title>QueryOwl</title>
7
7
  <link rel="icon" href="data:,">
8
8
  <%= inline_styles %>
9
+ <%= javascript_importmap_tags "query_owl" %>
9
10
  </head>
10
11
  <body>
11
12
  <%= yield %>
@@ -1,43 +1,81 @@
1
1
  <div class="qo-header">
2
2
  <h1>QueryOwl</h1>
3
- <p>Last <%= @events.length %> detected event<%= "s" unless @events.length == 1 %> (newest first)</p>
4
3
  </div>
5
4
 
6
- <% if @events.empty? %>
7
- <p class="qo-empty">No events detected yet.</p>
8
- <% else %>
9
- <table class="qo-table">
10
- <thead>
11
- <tr>
12
- <th>Type</th>
13
- <th>SQL / Details</th>
14
- <th>Info</th>
15
- <th>Request</th>
16
- <th>Recorded At</th>
17
- <th>Backtrace</th>
18
- </tr>
19
- </thead>
20
- <tbody>
21
- <% @events.each do |event| %>
5
+ <form class="qo-filters" action="<%= slow_queries_path %>" method="get"
6
+ data-controller="table-filter"
7
+ data-turbo-frame="qo-events">
8
+ <input type="hidden" name="sort" value="<%= @sort %>">
9
+ <input type="hidden" name="direction" value="<%= @direction %>">
10
+
11
+ <label for="qo-type-filter">Type</label>
12
+ <select id="qo-type-filter" name="type" class="qo-select"
13
+ data-action="change->table-filter#select">
14
+ <option value="">All</option>
15
+ <option value="n_plus_one" <%= "selected" if @type_filter == "n_plus_one" %>>N+1</option>
16
+ <option value="slow_query" <%= "selected" if @type_filter == "slow_query" %>>Slow Query</option>
17
+ <option value="unused_eager_load" <%= "selected" if @type_filter == "unused_eager_load" %>>Unused Eager Load</option>
18
+ </select>
19
+
20
+ <label for="qo-controller-filter">Controller</label>
21
+ <div class="qo-input-wrapper">
22
+ <input id="qo-controller-filter" type="text" name="controller" class="qo-input"
23
+ placeholder="e.g. widgets"
24
+ value="<%= @controller_filter %>"
25
+ data-table-filter-target="controllerInput"
26
+ data-action="input->table-filter#filter">
27
+ <button type="button" class="qo-input-clear" aria-label="Clear controller filter"
28
+ data-table-filter-target="clearButton"
29
+ data-action="click->table-filter#clearController"
30
+ <%= "hidden" if @controller_filter.blank? %>>✕</button>
31
+ </div>
32
+
33
+ <% if @type_filter.present? || @controller_filter.present? %>
34
+ <%= link_to "Clear", slow_queries_path, class: "qo-btn qo-btn--muted" %>
35
+ <% end %>
36
+ </form>
37
+
38
+ <turbo-frame id="qo-events">
39
+ <p class="qo-summary qo-muted">
40
+ <%= @events.length %> event<%= "s" unless @events.length == 1 %><%= " (filtered)" if @type_filter.present? || @controller_filter.present? %>
41
+ </p>
42
+
43
+ <% if @events.empty? %>
44
+ <p class="qo-empty">No events detected yet.</p>
45
+ <% else %>
46
+ <table class="qo-table">
47
+ <thead>
22
48
  <tr>
23
- <td><span class="qo-badge qo-badge--<%= event[:type] %>"><%= event[:type].to_s.tr("_", " ") %></span></td>
24
- <td class="qo-monospace"><%= event[:sql] || "#{event[:model]}##{event[:association]}" %></td>
25
- <td class="qo-muted">
26
- <% if event[:count] %>count: <%= event[:count] %><% end %>
27
- <% if event[:duration_ms] %><%= event[:duration_ms] %>ms<% end %>
28
- </td>
29
- <td class="qo-request qo-muted">
30
- <% if event[:controller] || event[:action] %>
31
- <span class="qo-monospace"><%= [event[:controller], event[:action]].compact.join("#") %></span>
32
- <% end %>
33
- <% if event[:path] %>
34
- <br><span class="qo-path"><%= event[:path] %></span>
35
- <% end %>
36
- </td>
37
- <td class="qo-muted"><%= event[:recorded_at]&.strftime("%H:%M:%S") %></td>
38
- <td class="qo-monospace qo-muted"><%= Array(event[:backtrace]).first %></td>
49
+ <%= sort_th("Type", "type", @sort, @direction) %>
50
+ <th>SQL / Details</th>
51
+ <%= sort_th("Info", "info", @sort, @direction) %>
52
+ <th>Request</th>
53
+ <%= sort_th("Recorded At", "recorded_at", @sort, @direction) %>
54
+ <th>Backtrace</th>
39
55
  </tr>
40
- <% end %>
41
- </tbody>
42
- </table>
43
- <% end %>
56
+ </thead>
57
+ <tbody>
58
+ <% @events.each do |event| %>
59
+ <tr>
60
+ <td><span class="qo-badge qo-badge--<%= event[:type] %>"><%= event[:type].to_s.tr("_", " ") %></span></td>
61
+ <td class="qo-monospace"><%= event[:sql] || "#{event[:model]}##{event[:association]}" %></td>
62
+ <td class="qo-muted">
63
+ <% if event[:count] %>count: <%= event[:count] %><% end %>
64
+ <% if event[:duration_ms] %><%= event[:duration_ms] %>ms<% end %>
65
+ </td>
66
+ <td class="qo-request qo-muted">
67
+ <% if event[:controller] || event[:action] %>
68
+ <span class="qo-monospace"><%= [event[:controller], event[:action]].compact.join("#") %></span>
69
+ <% end %>
70
+ <% if event[:path] %>
71
+ <br><span class="qo-path"><%= event[:path] %></span>
72
+ <% end %>
73
+ </td>
74
+ <td class="qo-muted"><%= event[:recorded_at]&.strftime("%H:%M:%S") %></td>
75
+ <td class="qo-monospace qo-muted"><%= Array(event[:backtrace]).first %></td>
76
+ </tr>
77
+ <% end %>
78
+ </tbody>
79
+ </table>
80
+ <% end %>
81
+ </turbo-frame>
@@ -0,0 +1,4 @@
1
+ pin "@hotwired/turbo", to: "https://cdn.jsdelivr.net/npm/@hotwired/turbo@8.0.23/dist/turbo.es2017-esm.js"
2
+ pin "@hotwired/stimulus", to: "https://cdn.jsdelivr.net/npm/@hotwired/stimulus@3.2.2/dist/stimulus.js"
3
+ pin "query_owl", to: "query_owl/application.js"
4
+ pin "query_owl/table_filter_controller", to: "query_owl/table_filter_controller.js"
@@ -1,8 +1,24 @@
1
+ require "turbo-rails"
2
+ require "importmap-rails"
3
+
1
4
  module QueryOwl
2
5
  class Engine < ::Rails::Engine
3
6
  isolate_namespace QueryOwl
4
7
  config.generators.api_only = true
5
8
 
9
+ initializer "query_owl.assets" do |app|
10
+ if app.config.respond_to?(:assets)
11
+ app.config.assets.paths << root.join("app/javascript")
12
+ end
13
+ end
14
+
15
+ initializer "query_owl.importmap", before: "importmap" do |app|
16
+ if app.config.respond_to?(:importmap)
17
+ app.config.importmap.paths << root.join("config/importmap.rb")
18
+ app.config.importmap.cache_sweepers << root.join("app/javascript")
19
+ end
20
+ end
21
+
6
22
  initializer "query_owl.subscribe" do
7
23
  ActiveSupport::Notifications.subscribe("sql.active_record") do |*args|
8
24
  next unless QueryOwl.config.enabled
@@ -1,3 +1,3 @@
1
1
  module QueryOwl
2
- VERSION = "0.6.0"
2
+ VERSION = "0.7.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: query_owl
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chuck Smith
@@ -23,6 +23,34 @@ dependencies:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
25
  version: '7.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: importmap-rails
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: turbo-rails
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
26
54
  description: A leaner alternative to Bullet. Detects N+1 queries and slow queries
27
55
  in development, logging structured warnings to your Rails logger without the noise.
28
56
  email:
@@ -38,14 +66,18 @@ files:
38
66
  - app/assets/stylesheets/query_owl/_02_layout.css
39
67
  - app/assets/stylesheets/query_owl/_03_table.css
40
68
  - app/assets/stylesheets/query_owl/_04_badges.css
69
+ - app/assets/stylesheets/query_owl/_05_filters.css
41
70
  - app/controllers/query_owl/application_controller.rb
42
71
  - app/controllers/query_owl/slow_queries_controller.rb
43
72
  - app/helpers/query_owl/application_helper.rb
73
+ - app/javascript/query_owl/application.js
74
+ - app/javascript/query_owl/table_filter_controller.js
44
75
  - app/jobs/query_owl/application_job.rb
45
76
  - app/mailers/query_owl/application_mailer.rb
46
77
  - app/models/query_owl/application_record.rb
47
78
  - app/views/layouts/query_owl/application.html.erb
48
79
  - app/views/query_owl/slow_queries/index.html.erb
80
+ - config/importmap.rb
49
81
  - config/routes.rb
50
82
  - lib/generators/query_owl/install/install_generator.rb
51
83
  - lib/generators/query_owl/install/templates/initializer.rb