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.
- checksums.yaml +4 -4
- data/README.md +10 -4
- data/app/assets/images/rails_pulse/dashboard.png +0 -0
- data/app/assets/images/rails_pulse/request.png +0 -0
- data/app/assets/stylesheets/rails_pulse/application.css +28 -5
- data/app/assets/stylesheets/rails_pulse/components/badge.css +13 -0
- data/app/assets/stylesheets/rails_pulse/components/base.css +12 -2
- data/app/assets/stylesheets/rails_pulse/components/collapsible.css +30 -0
- data/app/assets/stylesheets/rails_pulse/components/popover.css +0 -1
- data/app/assets/stylesheets/rails_pulse/components/row.css +55 -3
- data/app/assets/stylesheets/rails_pulse/components/sidebar_menu.css +23 -0
- data/app/controllers/concerns/zoom_range_concern.rb +31 -0
- data/app/controllers/rails_pulse/application_controller.rb +5 -1
- data/app/controllers/rails_pulse/queries_controller.rb +46 -1
- data/app/controllers/rails_pulse/requests_controller.rb +14 -1
- data/app/controllers/rails_pulse/routes_controller.rb +40 -1
- data/app/helpers/rails_pulse/chart_helper.rb +15 -7
- data/app/javascript/rails_pulse/application.js +34 -3
- data/app/javascript/rails_pulse/controllers/collapsible_controller.js +32 -0
- data/app/javascript/rails_pulse/controllers/color_scheme_controller.js +2 -1
- data/app/javascript/rails_pulse/controllers/expandable_rows_controller.js +58 -0
- data/app/javascript/rails_pulse/controllers/index_controller.js +241 -11
- data/app/javascript/rails_pulse/controllers/popover_controller.js +28 -4
- data/app/javascript/rails_pulse/controllers/table_sort_controller.js +14 -0
- data/app/models/rails_pulse/queries/cards/average_query_times.rb +19 -19
- data/app/models/rails_pulse/queries/cards/execution_rate.rb +13 -8
- data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +13 -8
- data/app/models/rails_pulse/query.rb +46 -0
- data/app/models/rails_pulse/routes/cards/average_response_times.rb +17 -19
- data/app/models/rails_pulse/routes/cards/error_rate_per_route.rb +13 -8
- data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +13 -8
- data/app/models/rails_pulse/routes/cards/request_count_totals.rb +13 -8
- data/app/services/rails_pulse/analysis/backtrace_analyzer.rb +256 -0
- data/app/services/rails_pulse/analysis/base_analyzer.rb +67 -0
- data/app/services/rails_pulse/analysis/explain_plan_analyzer.rb +206 -0
- data/app/services/rails_pulse/analysis/index_recommendation_engine.rb +326 -0
- data/app/services/rails_pulse/analysis/n_plus_one_detector.rb +241 -0
- data/app/services/rails_pulse/analysis/query_characteristics_analyzer.rb +146 -0
- data/app/services/rails_pulse/analysis/suggestion_generator.rb +217 -0
- data/app/services/rails_pulse/query_analysis_service.rb +125 -0
- data/app/views/layouts/rails_pulse/_sidebar_menu.html.erb +0 -1
- data/app/views/layouts/rails_pulse/application.html.erb +0 -2
- data/app/views/rails_pulse/components/_breadcrumbs.html.erb +1 -1
- data/app/views/rails_pulse/components/_code_panel.html.erb +17 -3
- data/app/views/rails_pulse/components/_empty_state.html.erb +1 -1
- data/app/views/rails_pulse/components/_metric_card.html.erb +27 -4
- data/app/views/rails_pulse/components/_panel.html.erb +1 -1
- data/app/views/rails_pulse/components/_sparkline_stats.html.erb +5 -7
- data/app/views/rails_pulse/components/_table_head.html.erb +6 -1
- data/app/views/rails_pulse/dashboard/index.html.erb +1 -1
- data/app/views/rails_pulse/operations/show.html.erb +17 -15
- data/app/views/rails_pulse/queries/_analysis_error.html.erb +15 -0
- data/app/views/rails_pulse/queries/_analysis_prompt.html.erb +27 -0
- data/app/views/rails_pulse/queries/_analysis_results.html.erb +87 -0
- data/app/views/rails_pulse/queries/_analysis_section.html.erb +39 -0
- data/app/views/rails_pulse/queries/_show_table.html.erb +1 -1
- data/app/views/rails_pulse/queries/_table.html.erb +1 -1
- data/app/views/rails_pulse/queries/index.html.erb +48 -51
- data/app/views/rails_pulse/queries/show.html.erb +56 -52
- data/app/views/rails_pulse/requests/_operations.html.erb +30 -43
- data/app/views/rails_pulse/requests/_table.html.erb +3 -1
- data/app/views/rails_pulse/requests/index.html.erb +48 -51
- data/app/views/rails_pulse/routes/_table.html.erb +1 -1
- data/app/views/rails_pulse/routes/index.html.erb +49 -52
- data/app/views/rails_pulse/routes/show.html.erb +4 -4
- data/config/routes.rb +5 -1
- data/db/migrate/20250916031656_add_analysis_to_rails_pulse_queries.rb +13 -0
- data/db/rails_pulse_schema.rb +9 -0
- data/lib/generators/rails_pulse/convert_to_migrations_generator.rb +65 -0
- data/lib/generators/rails_pulse/install_generator.rb +71 -18
- data/lib/generators/rails_pulse/templates/migrations/install_rails_pulse_tables.rb +22 -0
- data/lib/generators/rails_pulse/templates/migrations/upgrade_rails_pulse_tables.rb +19 -0
- data/lib/generators/rails_pulse/upgrade_generator.rb +225 -0
- data/lib/rails_pulse/version.rb +1 -1
- data/lib/tasks/rails_pulse.rake +27 -8
- data/public/rails-pulse-assets/rails-pulse.css +1 -1
- data/public/rails-pulse-assets/rails-pulse.css.map +1 -1
- data/public/rails-pulse-assets/rails-pulse.js +53 -53
- data/public/rails-pulse-assets/rails-pulse.js.map +4 -4
- metadata +23 -5
- data/app/assets/images/rails_pulse/rails-pulse-logo.png +0 -0
- data/app/assets/images/rails_pulse/routes.png +0 -0
- 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:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 11728d425611e7ab3a9edb171f05638a783d1c8d3ded8dff112bb615e8a18d11
|
4
|
+
data.tar.gz: 3e16c2d59f4f4bd8d2df49625d9f6005edc98dec701212a6052f4abfd1cac6f3
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
-
<
|
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)
|
Binary file
|
Binary file
|
@@ -4,25 +4,48 @@
|
|
4
4
|
|
5
5
|
a {
|
6
6
|
text-decoration: underline;
|
7
|
-
color:
|
7
|
+
color: var(--color-link);
|
8
8
|
}
|
9
9
|
|
10
10
|
#header {
|
11
|
-
background-color:
|
11
|
+
background-color: var(--header-bg);
|
12
12
|
}
|
13
13
|
|
14
14
|
#header a {
|
15
|
-
color:
|
15
|
+
color: var(--header-link);
|
16
|
+
text-decoration: none;
|
16
17
|
}
|
17
18
|
|
18
19
|
#header a:hover {
|
19
|
-
background-color:
|
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:
|
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-
|
34
|
-
|
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
|
+
}
|
@@ -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
|
-
/*
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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: "
|
69
|
-
|
70
|
-
|
71
|
-
|
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
|
-
|
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
|
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-
|
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
|
}
|