rails_pulse 0.1.2 → 0.1.3

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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +10 -4
  3. data/app/assets/images/rails_pulse/dashboard.png +0 -0
  4. data/app/assets/images/rails_pulse/request.png +0 -0
  5. data/app/assets/stylesheets/rails_pulse/application.css +28 -5
  6. data/app/assets/stylesheets/rails_pulse/components/badge.css +13 -0
  7. data/app/assets/stylesheets/rails_pulse/components/base.css +12 -2
  8. data/app/assets/stylesheets/rails_pulse/components/collapsible.css +30 -0
  9. data/app/assets/stylesheets/rails_pulse/components/popover.css +0 -1
  10. data/app/assets/stylesheets/rails_pulse/components/row.css +55 -3
  11. data/app/assets/stylesheets/rails_pulse/components/sidebar_menu.css +23 -0
  12. data/app/controllers/concerns/zoom_range_concern.rb +31 -0
  13. data/app/controllers/rails_pulse/application_controller.rb +5 -1
  14. data/app/controllers/rails_pulse/queries_controller.rb +46 -1
  15. data/app/controllers/rails_pulse/requests_controller.rb +14 -1
  16. data/app/controllers/rails_pulse/routes_controller.rb +40 -1
  17. data/app/helpers/rails_pulse/chart_helper.rb +15 -7
  18. data/app/javascript/rails_pulse/application.js +34 -3
  19. data/app/javascript/rails_pulse/controllers/collapsible_controller.js +32 -0
  20. data/app/javascript/rails_pulse/controllers/color_scheme_controller.js +2 -1
  21. data/app/javascript/rails_pulse/controllers/expandable_rows_controller.js +58 -0
  22. data/app/javascript/rails_pulse/controllers/index_controller.js +241 -11
  23. data/app/javascript/rails_pulse/controllers/popover_controller.js +28 -4
  24. data/app/javascript/rails_pulse/controllers/table_sort_controller.js +14 -0
  25. data/app/models/rails_pulse/queries/cards/average_query_times.rb +19 -19
  26. data/app/models/rails_pulse/queries/cards/execution_rate.rb +13 -8
  27. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +13 -8
  28. data/app/models/rails_pulse/query.rb +46 -0
  29. data/app/models/rails_pulse/routes/cards/average_response_times.rb +17 -19
  30. data/app/models/rails_pulse/routes/cards/error_rate_per_route.rb +13 -8
  31. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +13 -8
  32. data/app/models/rails_pulse/routes/cards/request_count_totals.rb +13 -8
  33. data/app/services/rails_pulse/analysis/backtrace_analyzer.rb +256 -0
  34. data/app/services/rails_pulse/analysis/base_analyzer.rb +67 -0
  35. data/app/services/rails_pulse/analysis/explain_plan_analyzer.rb +206 -0
  36. data/app/services/rails_pulse/analysis/index_recommendation_engine.rb +326 -0
  37. data/app/services/rails_pulse/analysis/n_plus_one_detector.rb +241 -0
  38. data/app/services/rails_pulse/analysis/query_characteristics_analyzer.rb +146 -0
  39. data/app/services/rails_pulse/analysis/suggestion_generator.rb +217 -0
  40. data/app/services/rails_pulse/query_analysis_service.rb +125 -0
  41. data/app/views/layouts/rails_pulse/_sidebar_menu.html.erb +0 -1
  42. data/app/views/layouts/rails_pulse/application.html.erb +0 -2
  43. data/app/views/rails_pulse/components/_breadcrumbs.html.erb +1 -1
  44. data/app/views/rails_pulse/components/_code_panel.html.erb +17 -3
  45. data/app/views/rails_pulse/components/_empty_state.html.erb +1 -1
  46. data/app/views/rails_pulse/components/_metric_card.html.erb +27 -4
  47. data/app/views/rails_pulse/components/_panel.html.erb +1 -1
  48. data/app/views/rails_pulse/components/_sparkline_stats.html.erb +5 -7
  49. data/app/views/rails_pulse/components/_table_head.html.erb +6 -1
  50. data/app/views/rails_pulse/dashboard/index.html.erb +1 -1
  51. data/app/views/rails_pulse/operations/show.html.erb +17 -15
  52. data/app/views/rails_pulse/queries/_analysis_error.html.erb +15 -0
  53. data/app/views/rails_pulse/queries/_analysis_prompt.html.erb +27 -0
  54. data/app/views/rails_pulse/queries/_analysis_results.html.erb +87 -0
  55. data/app/views/rails_pulse/queries/_analysis_section.html.erb +39 -0
  56. data/app/views/rails_pulse/queries/_show_table.html.erb +1 -1
  57. data/app/views/rails_pulse/queries/_table.html.erb +1 -1
  58. data/app/views/rails_pulse/queries/index.html.erb +48 -51
  59. data/app/views/rails_pulse/queries/show.html.erb +56 -52
  60. data/app/views/rails_pulse/requests/_operations.html.erb +30 -43
  61. data/app/views/rails_pulse/requests/_table.html.erb +3 -1
  62. data/app/views/rails_pulse/requests/index.html.erb +48 -51
  63. data/app/views/rails_pulse/routes/_table.html.erb +1 -1
  64. data/app/views/rails_pulse/routes/index.html.erb +49 -52
  65. data/app/views/rails_pulse/routes/show.html.erb +4 -4
  66. data/config/routes.rb +5 -1
  67. data/db/migrate/20250916031656_add_analysis_to_rails_pulse_queries.rb +13 -0
  68. data/db/rails_pulse_schema.rb +9 -0
  69. data/lib/generators/rails_pulse/convert_to_migrations_generator.rb +65 -0
  70. data/lib/generators/rails_pulse/install_generator.rb +71 -18
  71. data/lib/generators/rails_pulse/templates/migrations/install_rails_pulse_tables.rb +22 -0
  72. data/lib/generators/rails_pulse/templates/migrations/upgrade_rails_pulse_tables.rb +19 -0
  73. data/lib/generators/rails_pulse/upgrade_generator.rb +225 -0
  74. data/lib/rails_pulse/version.rb +1 -1
  75. data/lib/tasks/rails_pulse.rake +27 -8
  76. data/public/rails-pulse-assets/rails-pulse.css +1 -1
  77. data/public/rails-pulse-assets/rails-pulse.css.map +1 -1
  78. data/public/rails-pulse-assets/rails-pulse.js +53 -53
  79. data/public/rails-pulse-assets/rails-pulse.js.map +4 -4
  80. metadata +23 -5
  81. data/app/assets/images/rails_pulse/rails-pulse-logo.png +0 -0
  82. data/app/assets/images/rails_pulse/routes.png +0 -0
  83. data/app/javascript/rails_pulse/controllers/expandable_row_controller.js +0 -67
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7c6b5219e5da388e0ec200a6382527d227ab0e2ee20faa11e3ce8e49992ce6b8
4
- data.tar.gz: 1a713357c2ab7eb454b269f358c9b9fb1939eef9e3d17c8cb7cf9a12cab8206c
3
+ metadata.gz: 11728d425611e7ab3a9edb171f05638a783d1c8d3ded8dff112bb615e8a18d11
4
+ data.tar.gz: 3e16c2d59f4f4bd8d2df49625d9f6005edc98dec701212a6052f4abfd1cac6f3
5
5
  SHA512:
6
- metadata.gz: cf37a0c4d1ac19450ae702ee22b4f460d8b16528a00603d1cbb0fbad6ff5d6b8caf1dca90ff9ec0834683f56651af76ec0d14465c244d3e95482c76a8fcf9ccc
7
- data.tar.gz: f150bd31c92462f5c1594368816aaaa60fe6e17ec4e4e0cb67d8b0ff7c0732be9d544df040548c6c80050fea6e66d6f21cf960dcec1b4af6bec4cbb4c318bb24
6
+ metadata.gz: 443645ed917ef9beea2d642ff8c4a7d2e57cc87559a09e74dfd75d20d18fb0ff11040eac6e825f3ff0af35637ea23757c88b24e78f35e32f64a7660168aa940a
7
+ data.tar.gz: d11227a7a11c24bff167104e712f9d075a394130a196938ed71b360e6cbc82a8f930672187b92d0009ed5c14fee882ff23d3d88a3df25b1ddeff3a8839452b5b
data/README.md CHANGED
@@ -60,7 +60,13 @@ Rails Pulse is a comprehensive performance monitoring and debugging gem that pro
60
60
 
61
61
  ## Screenshots
62
62
 
63
- <img src="app/assets/images/rails_pulse/dashboard.png" alt="Rails Pulse" />
63
+ <table>
64
+ <tr>
65
+ <td><img src="app/assets/images/rails_pulse/dashboard.png" alt="Rails Pulse Dashboard" width="400" /></td>
66
+ <td><img src="app/assets/images/rails_pulse/request.png" alt="Rails Pulse Requests" width="400" /></td>
67
+ </tr>
68
+ </table>
69
+
64
70
 
65
71
  ## Getting Started
66
72
 
@@ -101,10 +107,10 @@ end
101
107
  Schedule background jobs:
102
108
 
103
109
  ```ruby
104
- # Schedule to run 5 minutes past every hour
110
+ # Schedule to run 5 minutes past every hour. cron: 5 * * * *
105
111
  RailsPulse::SummaryJob.perform_later
106
112
 
107
- # Schedule to run daily
113
+ # Schedule to run daily. cron: 0 1 * * *
108
114
  RailsPulse::CleanupJob.perform_later
109
115
  ```
110
116
 
@@ -447,7 +453,7 @@ Test individual databases locally:
447
453
  # Test with SQLite (default)
448
454
  rails test:all
449
455
 
450
- # Test with PostgreSQL
456
+ # Test with PostgreSQL
451
457
  DB=postgresql FORCE_DB_CONFIG=true rails test:all
452
458
 
453
459
  # Test with MySQL (local only)
@@ -4,25 +4,48 @@
4
4
 
5
5
  a {
6
6
  text-decoration: underline;
7
- color: #0048b5;
7
+ color: var(--color-link);
8
8
  }
9
9
 
10
10
  #header {
11
- background-color: #ffc91f;
11
+ background-color: var(--header-bg);
12
12
  }
13
13
 
14
14
  #header a {
15
- color: black
15
+ color: var(--header-link);
16
+ text-decoration: none;
16
17
  }
17
18
 
18
19
  #header a:hover {
19
- background-color: #ffe284;
20
+ background-color: transparent;
21
+ text-decoration: underline;
20
22
  }
21
23
 
22
24
  a:hover {
23
25
  cursor: pointer;
24
26
  }
25
27
 
28
+ /* Dark mode */
29
+
30
+ /* Dark scheme tweaks via component variables */
31
+ html[data-color-scheme="dark"] .card {
32
+ /* Scope card surfaces slightly darker than page */
33
+ --color-bg: rgb(47, 47, 47);
34
+ --color-border: rgb(64, 64, 64);
35
+ }
36
+
37
+ /* Header colors are handled by --header-* tokens in base.css */
38
+
39
+ html[data-color-scheme="dark"] .badge--positive-inverse,
40
+ html[data-color-scheme="dark"] .badge--negative-inverse {
41
+ --badge-background: rgb(47, 47, 47);
42
+ }
43
+
44
+ html[data-color-scheme="dark"] .input {
45
+ --input-background: #535252;
46
+ --input-border-color: #7e7d7d;
47
+ }
48
+
26
49
  .hidden {
27
50
  display: none;
28
51
  }
@@ -66,7 +89,7 @@ a:hover {
66
89
  height: 16px;
67
90
  padding: 2px;
68
91
  position: absolute;
69
- top: 11px;
92
+ top: 20px;
70
93
  }
71
94
 
72
95
  /* REQUEST OPERATIONS BAR */
@@ -56,3 +56,16 @@
56
56
  --badge-border-color: transparent;
57
57
  --badge-color: var(--color-negative);
58
58
  }
59
+
60
+ /* Trend badge icon lightening (dark mode only) */
61
+ .badge--trend rails-pulse-icon { color: var(--badge-color, currentColor); }
62
+ html[data-color-scheme="dark"] .badge--trend rails-pulse-icon {
63
+ /* Lighten icon relative to badge text color for contrast */
64
+ color: color-mix(in srgb, var(--badge-color) 55%, white 45%);
65
+ }
66
+
67
+ /* Trend amount lightening (dark mode only) */
68
+ .badge--trend .badge__trend-amount { color: var(--badge-color, currentColor); }
69
+ html[data-color-scheme="dark"] .badge--trend .badge__trend-amount {
70
+ color: color-mix(in srgb, var(--badge-color) 55%, white 45%);
71
+ }
@@ -5,6 +5,10 @@
5
5
  --color-text-reversed: white;
6
6
  --color-text-subtle: var(--zinc-500);
7
7
  --color-link: var(--blue-700);
8
+ /* Header tokens */
9
+ --header-bg: #ffc91f;
10
+ --header-link: black;
11
+ --header-link-hover-bg: #ffe284;
8
12
  --color-border-light: var(--zinc-100);
9
13
  --color-border: var(--zinc-200);
10
14
  --color-border-dark: var(--zinc-400);
@@ -30,8 +34,9 @@ html[data-color-scheme="dark"] {
30
34
  --color-bg: var(--zinc-800);
31
35
  --color-text: white;
32
36
  --color-text-reversed: black;
33
- --color-text-subtle: var(--zinc-400);
34
- --color-link: var(--blue-400);
37
+ --color-text-subtle: var(--zinc-300);
38
+ /* Use brand yellow for links in dark mode */
39
+ --color-link: #ffc91f;
35
40
  --color-border-light: var(--zinc-900);
36
41
  --color-border: var(--zinc-800);
37
42
  --color-border-dark: var(--zinc-600);
@@ -39,6 +44,11 @@ html[data-color-scheme="dark"] {
39
44
  --color-selected-dark: var(--blue-800);
40
45
  --color-highlight: var(--yellow-900);
41
46
 
47
+ /* Header tokens */
48
+ --header-bg: rgb(32, 32, 32);
49
+ --header-link: #ffc91f;
50
+ --header-link-hover-bg: #ffe284; /* keep existing hover color */
51
+
42
52
  /* Accent colors */
43
53
  --color-primary: var(--zinc-50);
44
54
  --color-secondary: var(--zinc-800);
@@ -0,0 +1,30 @@
1
+ .collapsible-code.collapsed pre {
2
+ max-height: 4.5em;
3
+ overflow: hidden;
4
+ position: relative;
5
+ }
6
+
7
+ .collapsible-code.collapsed pre::after {
8
+ content: '';
9
+ position: absolute;
10
+ bottom: 0;
11
+ left: 0;
12
+ right: 0;
13
+ height: 1em;
14
+ background: linear-gradient(transparent, var(--color-border-light));
15
+ pointer-events: none;
16
+ }
17
+
18
+ .collapsible-toggle {
19
+ margin-top: 0.5rem;
20
+ font-size: 0.875rem;
21
+ color: var(--color-link);
22
+ text-decoration: underline;
23
+ transform: lowercase;
24
+ cursor: pointer;
25
+ border: none;
26
+ background: none;
27
+ padding: 0;
28
+ font-weight: normal;
29
+ margin-left: 10px;
30
+ }
@@ -31,6 +31,5 @@
31
31
  left: var(--popover-x, 0) !important;
32
32
  top: var(--popover-y, 0) !important;
33
33
  margin: 0 !important;
34
- inset: unset !important;
35
34
  }
36
35
  }
@@ -3,22 +3,74 @@
3
3
  justify-content: space-between;
4
4
  width: 100%;
5
5
  gap: var(--column-gap, 0.5rem);
6
+ align-items: stretch;
6
7
  }
7
8
 
8
9
  .row > * {
9
10
  flex: 1;
10
11
  min-width: 0;
12
+ display: flex;
13
+ flex-direction: column;
11
14
  }
12
15
 
13
- /* Stack items on smaller screens */
16
+ /* Ensure metric cards and their panels stretch to full height */
17
+ .row > .grid-item {
18
+ display: flex;
19
+ flex-direction: column;
20
+ }
21
+
22
+ .row > .grid-item > * {
23
+ flex: 1;
24
+ }
25
+
26
+ /* Responsive layout for screens smaller than 768px */
14
27
  @media (max-width: 768px) {
15
28
  .row {
16
- flex-direction: column;
29
+ display: flex;
30
+ flex-wrap: wrap;
31
+ justify-content: space-between;
17
32
  gap: 0.5rem;
33
+ align-items: flex-start;
18
34
  }
19
35
 
20
36
  .row > * {
37
+ flex: 0 0 calc(50% - 0.25rem);
38
+ min-width: 0;
39
+ height: auto;
40
+ }
41
+
42
+ .row > .grid-item {
43
+ height: auto;
44
+ }
45
+
46
+ .row > .grid-item > * {
21
47
  flex: none;
22
- width: 100%;
48
+ }
49
+
50
+ /* Tables should stack in single column on mobile */
51
+ .row:has(.table-container) > * {
52
+ flex: 0 0 100%;
53
+ }
54
+
55
+ /* Single column for very small screens */
56
+ @media (max-width: 480px) {
57
+ .row > * {
58
+ flex: 0 0 100%;
59
+ }
60
+
61
+ .row > .grid-item {
62
+ min-height: auto;
63
+ }
64
+
65
+ /* Make metric cards more compact on mobile */
66
+ .row > .grid-item .card {
67
+ padding: var(--size-3);
68
+ }
69
+
70
+ /* Make charts smaller on mobile */
71
+ .row > .grid-item .chart-container {
72
+ height: 60px !important;
73
+ max-height: 60px;
74
+ }
23
75
  }
24
76
  }
@@ -77,3 +77,26 @@
77
77
  padding: var(--size-0_5) var(--size-2);
78
78
  row-gap: var(--size-1);
79
79
  }
80
+
81
+ /* Sheet component styles for mobile menu */
82
+ .sheet {
83
+ border: 0;
84
+ background: var(--color-bg);
85
+ max-block-size: none;
86
+ max-inline-size: none;
87
+ padding: 0;
88
+ }
89
+
90
+ .sheet--left {
91
+ block-size: 100vh;
92
+ inline-size: var(--sheet-size, 288px);
93
+ inset-block-start: 0;
94
+ inset-inline-start: 0;
95
+ }
96
+
97
+ .sheet__content {
98
+ block-size: 100%;
99
+ display: flex;
100
+ flex-direction: column;
101
+ overflow-y: auto;
102
+ }
@@ -2,10 +2,22 @@ module ZoomRangeConcern
2
2
  extend ActiveSupport::Concern
3
3
 
4
4
  def setup_zoom_range(main_start_time, main_end_time)
5
+ # Extract column selection parameter (but don't delete it yet - we need it for the view)
6
+ selected_column_time = params[:selected_column_time]
7
+
5
8
  # Extract zoom parameters from params (this removes them from params)
6
9
  zoom_start = params.delete(:zoom_start_time)
7
10
  zoom_end = params.delete(:zoom_end_time)
8
11
 
12
+ # Handle column selection with highest precedence for table filtering
13
+ if selected_column_time
14
+ column_start, column_end = normalize_column_time(selected_column_time.to_i, main_start_time, main_end_time)
15
+ table_start_time = column_start
16
+ table_end_time = column_end
17
+ # Don't set zoom times for column selection - let chart show full range
18
+ return [ zoom_start, zoom_end, table_start_time, table_end_time ]
19
+ end
20
+
9
21
  # Normalize zoom times to beginning/end of day or hour like we do for main time range
10
22
  if zoom_start && zoom_end
11
23
  zoom_start, zoom_end = normalize_zoom_times(zoom_start.to_i, zoom_end.to_i)
@@ -20,6 +32,25 @@ module ZoomRangeConcern
20
32
 
21
33
  private
22
34
 
35
+ def normalize_column_time(column_time, main_start_time, main_end_time)
36
+ # Determine period type based on main time range (same logic as ChartTableConcern)
37
+ time_diff_hours = (main_end_time - main_start_time) / 3600.0
38
+
39
+ if time_diff_hours <= 25
40
+ # Hourly period - normalize to beginning/end of hour
41
+ column_time_obj = Time.zone&.at(column_time) || Time.at(column_time)
42
+ start_time = column_time_obj&.beginning_of_hour || column_time_obj
43
+ end_time = column_time_obj&.end_of_hour || column_time_obj
44
+ else
45
+ # Daily period - normalize to beginning/end of day
46
+ column_time_obj = Time.zone&.at(column_time) || Time.at(column_time)
47
+ start_time = column_time_obj&.beginning_of_day || column_time_obj
48
+ end_time = column_time_obj&.end_of_day || column_time_obj
49
+ end
50
+
51
+ [ start_time.to_i, end_time.to_i ]
52
+ end
53
+
23
54
  def normalize_zoom_times(start_time, end_time)
24
55
  time_diff = (end_time - start_time) / 3600.0
25
56
 
@@ -5,7 +5,11 @@ module RailsPulse
5
5
  def set_pagination_limit(limit = nil)
6
6
  limit = limit || params[:limit]
7
7
  session[:pagination_limit] = limit.to_i if limit.present?
8
- render json: { status: "ok" }
8
+
9
+ # Render JSON for direct API calls or AJAX requests (but not turbo frame requests)
10
+ if (request.xhr? && !turbo_frame_request?) || (request.patch? && action_name == "set_pagination_limit")
11
+ render json: { status: "ok" }
12
+ end
9
13
  end
10
14
 
11
15
  private
@@ -2,7 +2,7 @@ module RailsPulse
2
2
  class QueriesController < ApplicationController
3
3
  include ChartTableConcern
4
4
 
5
- before_action :set_query, only: :show
5
+ before_action :set_query, only: [ :show, :analyze ]
6
6
 
7
7
  def index
8
8
  setup_metric_cards
@@ -14,6 +14,40 @@ module RailsPulse
14
14
  setup_chart_and_table_data
15
15
  end
16
16
 
17
+ def analyze
18
+ begin
19
+ @analysis_results = QueryAnalysisService.analyze_query(@query.id)
20
+
21
+ respond_to do |format|
22
+ format.turbo_stream {
23
+ render turbo_stream: turbo_stream.replace(
24
+ "query_analysis",
25
+ partial: "rails_pulse/queries/analysis_section",
26
+ locals: { query: @query.reload }
27
+ )
28
+ }
29
+ format.html {
30
+ redirect_to query_path(@query), notice: "Query analysis completed successfully."
31
+ }
32
+ end
33
+ rescue => e
34
+ Rails.logger.error("[QueryAnalysis] Analysis failed for query #{@query.id}: #{e.message}")
35
+
36
+ respond_to do |format|
37
+ format.turbo_stream {
38
+ render turbo_stream: turbo_stream.replace(
39
+ "query_analysis",
40
+ partial: "rails_pulse/queries/analysis_section",
41
+ locals: { query: @query, error_message: "Analysis failed: #{e.message}" }
42
+ )
43
+ }
44
+ format.html {
45
+ redirect_to query_path(@query), alert: "Query analysis failed: #{e.message}"
46
+ }
47
+ end
48
+ end
49
+ end
50
+
17
51
  private
18
52
 
19
53
  def chart_model
@@ -75,7 +109,18 @@ module RailsPulse
75
109
 
76
110
  def build_table_results
77
111
  if show_action?
112
+ # Only show operations that belong to time periods where we have query summaries
113
+ # This ensures the table data is consistent with the chart data
78
114
  @ransack_query.result
115
+ .joins(<<~SQL)
116
+ INNER JOIN rails_pulse_summaries ON
117
+ rails_pulse_summaries.summarizable_id = rails_pulse_operations.query_id AND
118
+ rails_pulse_summaries.summarizable_type = 'RailsPulse::Query' AND
119
+ rails_pulse_summaries.period_type = '#{period_type}' AND
120
+ rails_pulse_operations.occurred_at >= rails_pulse_summaries.period_start AND
121
+ rails_pulse_operations.occurred_at < rails_pulse_summaries.period_end
122
+ SQL
123
+ .distinct
79
124
  else
80
125
  Queries::Tables::Index.new(
81
126
  ransack_query: @ransack_query,
@@ -40,7 +40,9 @@ module RailsPulse
40
40
  def build_chart_ransack_params(ransack_params)
41
41
  base_params = ransack_params.except(:s).merge(
42
42
  period_start_gteq: Time.at(@start_time),
43
- period_start_lt: Time.at(@end_time)
43
+ period_start_lt: Time.at(@end_time),
44
+ summarizable_type_eq: "RailsPulse::Request",
45
+ summarizable_id_eq: 0
44
46
  )
45
47
 
46
48
  # Only add duration filter if we have a meaningful threshold
@@ -62,8 +64,18 @@ module RailsPulse
62
64
  end
63
65
 
64
66
  def build_table_results
67
+ # Only show requests that belong to time periods where we have overall request summaries
68
+ # This ensures the table data is consistent with the chart data
65
69
  @ransack_query.result
66
70
  .joins(:route)
71
+ .joins(<<~SQL)
72
+ INNER JOIN rails_pulse_summaries ON
73
+ rails_pulse_summaries.summarizable_id = 0 AND
74
+ rails_pulse_summaries.summarizable_type = 'RailsPulse::Request' AND
75
+ rails_pulse_summaries.period_type = '#{period_type}' AND
76
+ rails_pulse_requests.occurred_at >= rails_pulse_summaries.period_start AND
77
+ rails_pulse_requests.occurred_at < rails_pulse_summaries.period_end
78
+ SQL
67
79
  .select(
68
80
  "rails_pulse_requests.id",
69
81
  "rails_pulse_requests.occurred_at",
@@ -72,6 +84,7 @@ module RailsPulse
72
84
  "rails_pulse_requests.route_id",
73
85
  "rails_pulse_routes.path"
74
86
  )
87
+ .distinct
75
88
  end
76
89
 
77
90
  def set_request
@@ -84,7 +84,25 @@ module RailsPulse
84
84
 
85
85
  def build_table_results
86
86
  if show_action?
87
- @ransack_query.result
87
+ # Only show requests that belong to time periods where we have route summaries
88
+ # This ensures the table data is consistent with the chart data
89
+ base_query = @ransack_query.result
90
+ .joins(<<~SQL)
91
+ INNER JOIN rails_pulse_summaries ON
92
+ rails_pulse_summaries.summarizable_id = rails_pulse_requests.route_id AND
93
+ rails_pulse_summaries.summarizable_type = 'RailsPulse::Route' AND
94
+ rails_pulse_summaries.period_type = '#{period_type}' AND
95
+ rails_pulse_requests.occurred_at >= rails_pulse_summaries.period_start AND
96
+ rails_pulse_requests.occurred_at < rails_pulse_summaries.period_end
97
+ SQL
98
+
99
+ # For PostgreSQL compatibility with DISTINCT + ORDER BY
100
+ # we need to include computed columns in SELECT when ordering by them
101
+ if ordering_by_computed_column?
102
+ base_query.select("rails_pulse_requests.*, #{status_indicator_sql} as status_indicator_value").distinct
103
+ else
104
+ base_query.distinct
105
+ end
88
106
  else
89
107
  Routes::Tables::Index.new(
90
108
  ransack_query: @ransack_query,
@@ -130,5 +148,26 @@ module RailsPulse
130
148
  def set_route
131
149
  @route = Route.find(params[:id])
132
150
  end
151
+
152
+ def ordering_by_computed_column?
153
+ # Check if we're ordering by status_indicator (computed column)
154
+ @ransack_query.sorts.any? { |sort| sort.name == "status_indicator" }
155
+ end
156
+
157
+ def status_indicator_sql
158
+ # Same logic as in the Request model's ransacker
159
+ config = RailsPulse.configuration rescue nil
160
+ thresholds = config&.request_thresholds || { slow: 500, very_slow: 1000, critical: 2000 }
161
+ slow = thresholds[:slow] || 500
162
+ very_slow = thresholds[:very_slow] || 1000
163
+ critical = thresholds[:critical] || 2000
164
+
165
+ "CASE
166
+ WHEN rails_pulse_requests.duration < #{slow} THEN 0
167
+ WHEN rails_pulse_requests.duration < #{very_slow} THEN 1
168
+ WHEN rails_pulse_requests.duration < #{critical} THEN 2
169
+ ELSE 3
170
+ END"
171
+ end
133
172
  end
134
173
  end
@@ -63,16 +63,24 @@ module RailsPulse
63
63
  end
64
64
 
65
65
  def sparkline_chart_options
66
+ # Compact sparkline columns that fill the canvas with no axes/labels/gaps
66
67
  base_chart_options.deep_merge({
67
68
  series: {
68
- type: "line",
69
- smooth: true,
70
- lineStyle: { width: 2 },
71
- symbol: "none"
69
+ type: "bar",
70
+ itemStyle: { borderRadius: [ 2, 2, 0, 0 ] },
71
+ barCategoryGap: "10%",
72
+ barGap: "0%"
73
+ },
74
+ yAxis: { show: false, splitLine: { show: false } },
75
+ xAxis: {
76
+ type: "category",
77
+ boundaryGap: true,
78
+ axisLine: { show: false },
79
+ axisTick: { show: false },
80
+ splitLine: { show: false },
81
+ axisLabel: { show: false }
72
82
  },
73
- yAxis: { show: false },
74
- xAxis: { splitLine: { show: false } },
75
- grid: { show: false }
83
+ grid: { left: 0, right: 0, top: 0, bottom: 0, containLabel: false, show: false }
76
84
  })
77
85
  end
78
86
 
@@ -16,7 +16,9 @@ import ColorSchemeController from "./controllers/color_scheme_controller";
16
16
  import PaginationController from "./controllers/pagination_controller";
17
17
  import TimezoneController from "./controllers/timezone_controller";
18
18
  import IconController from "./controllers/icon_controller";
19
- import ExpandableRowController from "./controllers/expandable_row_controller";
19
+ import ExpandableRowsController from "./controllers/expandable_rows_controller";
20
+ import CollapsibleController from "./controllers/collapsible_controller";
21
+ import TableSortController from "./controllers/table_sort_controller";
20
22
 
21
23
  const application = Application.start();
22
24
 
@@ -41,7 +43,9 @@ application.register("rails-pulse--color-scheme", ColorSchemeController);
41
43
  application.register("rails-pulse--pagination", PaginationController);
42
44
  application.register("rails-pulse--timezone", TimezoneController);
43
45
  application.register("rails-pulse--icon", IconController);
44
- application.register("rails-pulse--expandable-row", ExpandableRowController);
46
+ application.register("rails-pulse--expandable-rows", ExpandableRowsController);
47
+ application.register("rails-pulse--collapsible", CollapsibleController);
48
+ application.register("rails-pulse--table-sort", TableSortController);
45
49
 
46
50
  // Ensure Turbo Frames are loaded after page load
47
51
  document.addEventListener('DOMContentLoaded', () => {
@@ -95,6 +99,32 @@ window.addEventListener('resize', function() {
95
99
  }
96
100
  });
97
101
 
102
+ // Apply axis label colors based on current color scheme
103
+ function applyChartAxisLabelColors() {
104
+ if (!window.RailsCharts || !window.RailsCharts.charts) return;
105
+ const scheme = document.documentElement.getAttribute('data-color-scheme');
106
+ const isDark = scheme === 'dark';
107
+ const axisColor = isDark ? '#ffffff' : '#999999';
108
+ Object.keys(window.RailsCharts.charts).forEach(function(chartID) {
109
+ const chart = window.RailsCharts.charts[chartID];
110
+ try {
111
+ chart.setOption({
112
+ xAxis: { axisLabel: { color: axisColor } },
113
+ yAxis: { axisLabel: { color: axisColor } }
114
+ });
115
+ } catch (e) {
116
+ // noop
117
+ }
118
+ });
119
+ }
120
+
121
+ // Initial apply after charts initialize and on scheme changes
122
+ document.addEventListener('DOMContentLoaded', () => {
123
+ // run shortly after load to allow charts to initialize
124
+ setTimeout(applyChartAxisLabelColors, 50);
125
+ });
126
+ document.addEventListener('rails-pulse:color-scheme-changed', applyChartAxisLabelColors);
127
+
98
128
  // Global function to initialize Rails Charts in any container.
99
129
  // This is needed as we render Rails Charts in Turbo Frames.
100
130
  window.initializeChartsInContainer = function(containerId) {
@@ -108,6 +138,8 @@ window.initializeChartsInContainer = function(containerId) {
108
138
  window[match[1]]();
109
139
  }
110
140
  });
141
+ // ensure colors are correct for any charts initialized in this container
142
+ setTimeout(applyChartAxisLabelColors, 10);
111
143
  });
112
144
  };
113
145
 
@@ -116,4 +148,3 @@ window.RailsPulse = {
116
148
  application,
117
149
  version: "1.0.0"
118
150
  };
119
-
@@ -0,0 +1,32 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["content", "toggle"]
5
+ static classes = ["collapsed"]
6
+
7
+ connect() {
8
+ this.collapse()
9
+ }
10
+
11
+ toggle() {
12
+ if (this.element.classList.contains(this.collapsedClass)) {
13
+ this.expand()
14
+ } else {
15
+ this.collapse()
16
+ }
17
+ }
18
+
19
+ collapse() {
20
+ this.element.classList.add(this.collapsedClass)
21
+ if (this.hasToggleTarget) {
22
+ this.toggleTarget.textContent = "show more"
23
+ }
24
+ }
25
+
26
+ expand() {
27
+ this.element.classList.remove(this.collapsedClass)
28
+ if (this.hasToggleTarget) {
29
+ this.toggleTarget.textContent = "show less"
30
+ }
31
+ }
32
+ }
@@ -13,8 +13,9 @@ export default class extends Controller {
13
13
  toggle(event) {
14
14
  event.preventDefault()
15
15
  const current = this.html.getAttribute("data-color-scheme") === "dark" ? "light" : "dark"
16
- console.log("Toggling color scheme to", current)
17
16
  this.html.setAttribute("data-color-scheme", current)
18
17
  localStorage.setItem(this.storageKey, current)
18
+ // Notify listeners (e.g., charts) that scheme changed
19
+ document.dispatchEvent(new CustomEvent('rails-pulse:color-scheme-changed', { detail: { scheme: current }}))
19
20
  }
20
21
  }