rails_pulse 0.1.1 → 0.1.2
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 +72 -176
- data/Rakefile +77 -2
- data/app/assets/stylesheets/rails_pulse/application.css +0 -12
- data/app/controllers/concerns/chart_table_concern.rb +21 -4
- data/app/controllers/concerns/response_range_concern.rb +6 -3
- data/app/controllers/concerns/time_range_concern.rb +5 -10
- data/app/controllers/concerns/zoom_range_concern.rb +1 -1
- data/app/controllers/rails_pulse/application_controller.rb +8 -4
- data/app/controllers/rails_pulse/dashboard_controller.rb +12 -0
- data/app/controllers/rails_pulse/queries_controller.rb +65 -50
- data/app/controllers/rails_pulse/requests_controller.rb +24 -12
- data/app/controllers/rails_pulse/routes_controller.rb +59 -24
- data/app/helpers/rails_pulse/application_helper.rb +0 -1
- data/app/helpers/rails_pulse/chart_formatters.rb +3 -3
- data/app/helpers/rails_pulse/chart_helper.rb +6 -2
- data/app/helpers/rails_pulse/status_helper.rb +10 -4
- data/app/javascript/rails_pulse/controllers/index_controller.js +117 -33
- data/app/javascript/rails_pulse/controllers/pagination_controller.js +17 -27
- data/app/jobs/rails_pulse/backfill_summaries_job.rb +41 -0
- data/app/jobs/rails_pulse/summary_job.rb +53 -0
- data/app/models/rails_pulse/dashboard/charts/average_response_time.rb +28 -7
- data/app/models/rails_pulse/dashboard/charts/p95_response_time.rb +18 -22
- data/app/models/rails_pulse/dashboard/tables/slow_queries.rb +18 -7
- data/app/models/rails_pulse/dashboard/tables/slow_routes.rb +34 -41
- data/app/models/rails_pulse/operation.rb +1 -1
- data/app/models/rails_pulse/queries/cards/average_query_times.rb +47 -23
- data/app/models/rails_pulse/queries/cards/execution_rate.rb +33 -26
- data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +34 -45
- data/app/models/rails_pulse/queries/charts/average_query_times.rb +23 -97
- data/app/models/rails_pulse/queries/tables/index.rb +74 -0
- data/app/models/rails_pulse/query.rb +1 -0
- data/app/models/rails_pulse/requests/charts/average_response_times.rb +23 -84
- data/app/models/rails_pulse/route.rb +1 -6
- data/app/models/rails_pulse/routes/cards/average_response_times.rb +45 -23
- data/app/models/rails_pulse/routes/cards/error_rate_per_route.rb +38 -45
- data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +34 -47
- data/app/models/rails_pulse/routes/cards/request_count_totals.rb +30 -25
- data/app/models/rails_pulse/routes/charts/average_response_times.rb +23 -100
- data/app/models/rails_pulse/routes/tables/index.rb +57 -40
- data/app/models/rails_pulse/summary.rb +143 -0
- data/app/services/rails_pulse/summary_service.rb +199 -0
- data/app/views/layouts/rails_pulse/application.html.erb +4 -4
- data/app/views/rails_pulse/components/_empty_state.html.erb +11 -0
- data/app/views/rails_pulse/components/_metric_card.html.erb +10 -24
- data/app/views/rails_pulse/dashboard/index.html.erb +54 -36
- data/app/views/rails_pulse/queries/_show_table.html.erb +1 -1
- data/app/views/rails_pulse/queries/_table.html.erb +10 -12
- data/app/views/rails_pulse/queries/index.html.erb +41 -34
- data/app/views/rails_pulse/queries/show.html.erb +38 -31
- data/app/views/rails_pulse/requests/_operations.html.erb +32 -26
- data/app/views/rails_pulse/requests/_table.html.erb +1 -3
- data/app/views/rails_pulse/requests/index.html.erb +42 -34
- data/app/views/rails_pulse/routes/_table.html.erb +13 -13
- data/app/views/rails_pulse/routes/index.html.erb +43 -35
- data/app/views/rails_pulse/routes/show.html.erb +42 -35
- data/config/initializers/rails_pulse.rb +0 -12
- data/db/migrate/20241222000001_create_rails_pulse_summaries.rb +54 -0
- data/db/rails_pulse_schema.rb +121 -0
- data/lib/generators/rails_pulse/install_generator.rb +41 -4
- data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +60 -0
- data/lib/generators/rails_pulse/templates/rails_pulse.rb +0 -12
- data/lib/rails_pulse/configuration.rb +0 -11
- data/lib/rails_pulse/engine.rb +0 -1
- data/lib/rails_pulse/version.rb +1 -1
- data/lib/tasks/rails_pulse.rake +58 -0
- 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 +1 -1
- data/public/rails-pulse-assets/rails-pulse.js.map +3 -3
- data/public/rails-pulse-assets/search.svg +43 -0
- metadata +27 -11
- data/app/controllers/rails_pulse/caches_controller.rb +0 -115
- data/app/helpers/rails_pulse/cached_component_helper.rb +0 -73
- data/app/models/rails_pulse/component_cache_key.rb +0 -33
- data/app/views/rails_pulse/caches/show.html.erb +0 -9
- data/db/migrate/20250227235904_create_routes.rb +0 -12
- data/db/migrate/20250227235915_create_requests.rb +0 -19
- data/db/migrate/20250228000000_create_queries.rb +0 -14
- data/db/migrate/20250228000056_create_operations.rb +0 -24
- data/lib/rails_pulse/migration.rb +0 -29
@@ -1,69 +1,108 @@
|
|
1
1
|
import { Controller } from "@hotwired/stimulus"
|
2
2
|
|
3
3
|
export default class extends Controller {
|
4
|
-
static targets = ["chart", "paginationLimit", "indexTable"]
|
4
|
+
static targets = ["chart", "paginationLimit", "indexTable"]
|
5
5
|
|
6
6
|
static values = {
|
7
7
|
chartId: String // The ID of the chart to be monitored
|
8
8
|
}
|
9
9
|
|
10
|
-
// Add
|
10
|
+
// Add properties for improved debouncing
|
11
11
|
lastTurboFrameRequestAt = 0;
|
12
|
+
pendingRequestTimeout = null;
|
13
|
+
pendingRequestData = null;
|
12
14
|
|
13
15
|
connect() {
|
14
16
|
// Listen for the custom event 'chart:initialized' to set up the chart.
|
15
17
|
// This event is sent from the RailsCharts library when the chart is ready.
|
16
18
|
this.handleChartInitialized = this.onChartInitialized.bind(this);
|
17
|
-
|
19
|
+
|
20
|
+
document.addEventListener('chart:rendered', this.handleChartInitialized);
|
18
21
|
|
19
22
|
// If the chart is already initialized (e.g., on back navigation), set up immediately
|
20
|
-
if (window.RailsCharts?.charts?.[this.chartIdValue]) {
|
23
|
+
if (window.RailsCharts?.charts?.[this.chartIdValue]) {
|
24
|
+
this.setup();
|
25
|
+
}
|
21
26
|
}
|
22
27
|
|
23
28
|
disconnect() {
|
24
29
|
// Remove the event listener from RailsCharts when the controller is disconnected
|
25
|
-
document.removeEventListener('chart:
|
30
|
+
document.removeEventListener('chart:rendered', this.handleChartInitialized);
|
26
31
|
|
27
32
|
// Remove chart event listeners if they exist
|
28
|
-
if (this.chartTarget) {
|
33
|
+
if (this.hasChartTarget && this.chartTarget) {
|
29
34
|
this.chartTarget.removeEventListener('mousedown', this.handleChartMouseDown);
|
30
35
|
this.chartTarget.removeEventListener('mouseup', this.handleChartMouseUp);
|
31
36
|
}
|
32
37
|
document.removeEventListener('mouseup', this.handleDocumentMouseUp);
|
38
|
+
|
39
|
+
// Clear any pending timeout
|
40
|
+
if (this.pendingRequestTimeout) {
|
41
|
+
clearTimeout(this.pendingRequestTimeout);
|
42
|
+
}
|
33
43
|
}
|
34
44
|
|
35
45
|
// After the chart is initialized, set up the event listeners and data tracking
|
36
46
|
onChartInitialized(event) {
|
37
|
-
if (event.detail.
|
47
|
+
if (event.detail.containerId === this.chartIdValue) {
|
48
|
+
this.setup();
|
49
|
+
}
|
38
50
|
}
|
39
51
|
|
40
52
|
setup() {
|
41
|
-
if (this.setupDone)
|
53
|
+
if (this.setupDone) {
|
54
|
+
return; // Prevent multiple setups
|
55
|
+
}
|
42
56
|
|
57
|
+
// We need both the chart target in DOM and the chart object from RailsCharts
|
58
|
+
let hasTarget = false;
|
59
|
+
try {
|
60
|
+
hasTarget = !!this.chartTarget;
|
61
|
+
} catch (e) {
|
62
|
+
hasTarget = false;
|
63
|
+
}
|
64
|
+
|
43
65
|
// Get the chart element which the RailsCharts library has created
|
44
66
|
this.chart = window.RailsCharts.charts[this.chartIdValue];
|
45
|
-
|
67
|
+
|
68
|
+
// Only proceed if we have BOTH the DOM target and the chart object
|
69
|
+
if (!hasTarget || !this.chart) {
|
70
|
+
return;
|
71
|
+
}
|
46
72
|
|
47
73
|
this.visibleData = this.getVisibleData();
|
48
74
|
this.setupChartEventListeners();
|
49
75
|
this.setupDone = true;
|
76
|
+
|
77
|
+
// Mark the chart as fully rendered for testing
|
78
|
+
if (hasTarget) {
|
79
|
+
document.getElementById(this.chartIdValue)?.setAttribute('data-chart-rendered', 'true');
|
80
|
+
}
|
50
81
|
}
|
51
82
|
|
52
83
|
// Add some event listeners to the chart so we can track the zoom changes
|
53
84
|
setupChartEventListeners() {
|
54
85
|
// When clicking on the chart, we want to store the current visible data so we can compare it later
|
55
|
-
this.handleChartMouseDown = () => {
|
86
|
+
this.handleChartMouseDown = () => {
|
87
|
+
this.visibleData = this.getVisibleData();
|
88
|
+
};
|
56
89
|
this.chartTarget.addEventListener('mousedown', this.handleChartMouseDown);
|
57
90
|
|
58
91
|
// When releasing the mouse button, we want to check if the visible data has changed
|
59
|
-
this.handleChartMouseUp = () => {
|
92
|
+
this.handleChartMouseUp = () => {
|
93
|
+
this.handleZoomChange();
|
94
|
+
};
|
60
95
|
this.chartTarget.addEventListener('mouseup', this.handleChartMouseUp);
|
61
96
|
|
62
97
|
// When the chart is zoomed, we want to check if the visible data has changed
|
63
|
-
this.chart.on('datazoom', () => {
|
98
|
+
this.chart.on('datazoom', () => {
|
99
|
+
this.handleZoomChange();
|
100
|
+
});
|
64
101
|
|
65
102
|
// When releasing the mouse button outside the chart, we want to check if the visible data has changed
|
66
|
-
this.handleDocumentMouseUp = () => {
|
103
|
+
this.handleDocumentMouseUp = () => {
|
104
|
+
this.handleZoomChange();
|
105
|
+
};
|
67
106
|
document.addEventListener('mouseup', this.handleDocumentMouseUp);
|
68
107
|
}
|
69
108
|
|
@@ -71,18 +110,37 @@ export default class extends Controller {
|
|
71
110
|
// The xAxis data and series data are sliced based on the start and end values of the dataZoom component.
|
72
111
|
// The series data will contain the actual data points that are visible in the chart.
|
73
112
|
getVisibleData() {
|
74
|
-
|
75
|
-
|
76
|
-
const xAxisData = currentOption.xAxis[0].data;
|
77
|
-
const seriesData = currentOption.series[0].data;
|
113
|
+
try {
|
114
|
+
const currentOption = this.chart.getOption();
|
78
115
|
|
79
|
-
|
80
|
-
|
116
|
+
if (!currentOption.dataZoom || currentOption.dataZoom.length === 0) {
|
117
|
+
return { xAxis: [], series: [] };
|
118
|
+
}
|
81
119
|
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
120
|
+
// Try to find the correct dataZoom component
|
121
|
+
let dataZoom = currentOption.dataZoom[1] || currentOption.dataZoom[0];
|
122
|
+
|
123
|
+
if (!currentOption.xAxis || !currentOption.xAxis[0] || !currentOption.xAxis[0].data) {
|
124
|
+
return { xAxis: [], series: [] };
|
125
|
+
}
|
126
|
+
|
127
|
+
if (!currentOption.series || !currentOption.series[0] || !currentOption.series[0].data) {
|
128
|
+
return { xAxis: [], series: [] };
|
129
|
+
}
|
130
|
+
|
131
|
+
const xAxisData = currentOption.xAxis[0].data;
|
132
|
+
const seriesData = currentOption.series[0].data;
|
133
|
+
|
134
|
+
const startValue = dataZoom.startValue || 0;
|
135
|
+
const endValue = dataZoom.endValue || xAxisData.length - 1;
|
136
|
+
|
137
|
+
return {
|
138
|
+
xAxis: xAxisData.slice(startValue, endValue + 1),
|
139
|
+
series: seriesData.slice(startValue, endValue + 1)
|
140
|
+
};
|
141
|
+
} catch (error) {
|
142
|
+
return { xAxis: [], series: [] };
|
143
|
+
}
|
86
144
|
}
|
87
145
|
|
88
146
|
// When the zoom level changes, we want to check if the visible data has changed
|
@@ -90,7 +148,10 @@ export default class extends Controller {
|
|
90
148
|
// we can update the table with the new data that is visible in the chart.
|
91
149
|
handleZoomChange() {
|
92
150
|
const newVisibleData = this.getVisibleData();
|
93
|
-
|
151
|
+
const newDataString = newVisibleData.xAxis.join();
|
152
|
+
const currentDataString = this.visibleData.xAxis.join();
|
153
|
+
|
154
|
+
if (newDataString !== currentDataString) {
|
94
155
|
this.visibleData = newVisibleData;
|
95
156
|
this.updateUrlWithZoomParams(newVisibleData);
|
96
157
|
this.sendTurboFrameRequest(newVisibleData);
|
@@ -124,14 +185,35 @@ export default class extends Controller {
|
|
124
185
|
window.history.replaceState({}, '', url);
|
125
186
|
}
|
126
187
|
|
127
|
-
//
|
128
|
-
// The server will then return the full page HTML with the updated table data wrapped in a turbo-frame.
|
129
|
-
// We will then replace the innerHTML of the turbo-frame with the new HTML.
|
188
|
+
// Improved debouncing with guaranteed final request
|
130
189
|
sendTurboFrameRequest(data) {
|
131
190
|
const now = Date.now();
|
132
|
-
|
133
|
-
|
134
|
-
|
191
|
+
const timeSinceLastRequest = now - this.lastTurboFrameRequestAt;
|
192
|
+
|
193
|
+
// Store the latest data for potential delayed execution
|
194
|
+
this.pendingRequestData = data;
|
195
|
+
|
196
|
+
// Clear any existing timeout
|
197
|
+
if (this.pendingRequestTimeout) {
|
198
|
+
clearTimeout(this.pendingRequestTimeout);
|
199
|
+
}
|
200
|
+
|
201
|
+
// If enough time has passed since last request, execute immediately
|
202
|
+
if (timeSinceLastRequest >= 1000) {
|
203
|
+
this.executeTurboFrameRequest(data);
|
204
|
+
} else {
|
205
|
+
// Otherwise, schedule execution for later to ensure final request goes through
|
206
|
+
const remainingTime = 1000 - timeSinceLastRequest;
|
207
|
+
this.pendingRequestTimeout = setTimeout(() => {
|
208
|
+
this.executeTurboFrameRequest(this.pendingRequestData);
|
209
|
+
this.pendingRequestTimeout = null;
|
210
|
+
}, remainingTime);
|
211
|
+
}
|
212
|
+
}
|
213
|
+
|
214
|
+
// Execute the actual AJAX request
|
215
|
+
executeTurboFrameRequest(data) {
|
216
|
+
this.lastTurboFrameRequestAt = Date.now();
|
135
217
|
|
136
218
|
// Start with the current page's URL
|
137
219
|
const url = new URL(window.location.href);
|
@@ -159,7 +241,9 @@ export default class extends Controller {
|
|
159
241
|
'Turbo-Frame': this.chartIdValue
|
160
242
|
}
|
161
243
|
})
|
162
|
-
.then(response =>
|
244
|
+
.then(response => {
|
245
|
+
return response.text();
|
246
|
+
})
|
163
247
|
.then(html => {
|
164
248
|
// Find the turbo-frame in the document using the target
|
165
249
|
const frame = this.indexTableTarget;
|
@@ -179,7 +263,7 @@ export default class extends Controller {
|
|
179
263
|
}
|
180
264
|
}
|
181
265
|
})
|
182
|
-
.catch(error => console.error('
|
266
|
+
.catch(error => console.error('[IndexController] Fetch error:', error));
|
183
267
|
}
|
184
268
|
|
185
269
|
// CSP-safe method to replace frame content using DOM methods
|
@@ -209,7 +293,7 @@ export default class extends Controller {
|
|
209
293
|
// Parse HTML safely
|
210
294
|
const parser = new DOMParser();
|
211
295
|
const doc = parser.parseFromString(html, 'text/html');
|
212
|
-
|
296
|
+
|
213
297
|
// Clear existing content
|
214
298
|
while (targetFrame.firstChild) {
|
215
299
|
targetFrame.removeChild(targetFrame.firstChild);
|
@@ -11,33 +11,25 @@ export default class extends Controller {
|
|
11
11
|
this.restorePaginationLimit()
|
12
12
|
}
|
13
13
|
|
14
|
-
// Update pagination limit
|
15
|
-
|
14
|
+
// Update pagination limit and refresh the turbo frame
|
15
|
+
updateLimit() {
|
16
16
|
const limit = this.limitTarget.value
|
17
17
|
|
18
|
-
// Save to session storage
|
18
|
+
// Save to session storage only - no server request needed
|
19
19
|
sessionStorage.setItem(this.storageKeyValue, limit)
|
20
20
|
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
// Reload the page to reflect the new pagination limit
|
34
|
-
// This preserves all current URL parameters including Ransack search params
|
35
|
-
window.location.reload()
|
36
|
-
} else {
|
37
|
-
throw new Error(`HTTP error! status: ${response.status}`)
|
38
|
-
}
|
39
|
-
} catch (error) {
|
40
|
-
console.error('Error updating pagination limit:', error)
|
21
|
+
// Find the closest turbo frame and reload it to apply new pagination
|
22
|
+
const turboFrame = this.element.closest('turbo-frame')
|
23
|
+
if (turboFrame) {
|
24
|
+
// Add the limit as a URL parameter so server picks it up
|
25
|
+
const currentUrl = new URL(window.location)
|
26
|
+
currentUrl.searchParams.set('limit', limit)
|
27
|
+
turboFrame.src = currentUrl.pathname + currentUrl.search
|
28
|
+
} else {
|
29
|
+
// Fallback to page reload if not within a turbo frame
|
30
|
+
const currentUrl = new URL(window.location)
|
31
|
+
currentUrl.searchParams.set('limit', limit)
|
32
|
+
window.location.href = currentUrl.pathname + currentUrl.search
|
41
33
|
}
|
42
34
|
}
|
43
35
|
|
@@ -60,10 +52,8 @@ export default class extends Controller {
|
|
60
52
|
// Only set if the current value is different (prevents unnecessary DOM updates)
|
61
53
|
if (this.limitTarget.value !== savedLimit) {
|
62
54
|
this.limitTarget.value = savedLimit
|
63
|
-
|
64
|
-
// Trigger a change event to ensure any other listeners are notified
|
65
|
-
this.limitTarget.dispatchEvent(new Event('change', { bubbles: true }))
|
55
|
+
// Don't trigger change event when restoring from session - prevents infinite loops
|
66
56
|
}
|
67
57
|
}
|
68
58
|
}
|
69
|
-
}
|
59
|
+
}
|
@@ -0,0 +1,41 @@
|
|
1
|
+
module RailsPulse
|
2
|
+
class BackfillSummariesJob < ApplicationJob
|
3
|
+
queue_as :low_priority
|
4
|
+
|
5
|
+
def perform(start_date, end_date, period_types = [ "hour", "day" ])
|
6
|
+
start_date = start_date.to_datetime
|
7
|
+
end_date = end_date.to_datetime
|
8
|
+
|
9
|
+
period_types.each do |period_type|
|
10
|
+
backfill_period(period_type, start_date, end_date)
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
private
|
15
|
+
|
16
|
+
def backfill_period(period_type, start_date, end_date)
|
17
|
+
current = Summary.normalize_period_start(period_type, start_date)
|
18
|
+
period_end = Summary.calculate_period_end(period_type, end_date)
|
19
|
+
|
20
|
+
while current <= period_end
|
21
|
+
Rails.logger.info "[RailsPulse] Backfilling #{period_type} summary for #{current}"
|
22
|
+
|
23
|
+
SummaryService.new(period_type, current).perform
|
24
|
+
|
25
|
+
current = advance_period(current, period_type)
|
26
|
+
|
27
|
+
# Add small delay to avoid overwhelming the database
|
28
|
+
sleep 0.1
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def advance_period(time, period_type)
|
33
|
+
case period_type
|
34
|
+
when "hour" then time + 1.hour
|
35
|
+
when "day" then time + 1.day
|
36
|
+
when "week" then time + 1.week
|
37
|
+
when "month" then time + 1.month
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
module RailsPulse
|
2
|
+
class SummaryJob < ApplicationJob
|
3
|
+
queue_as :low_priority
|
4
|
+
|
5
|
+
def perform(target_hour = nil)
|
6
|
+
target_hour ||= 1.hour.ago.beginning_of_hour
|
7
|
+
|
8
|
+
# Always run hourly summary
|
9
|
+
process_hourly_summary(target_hour)
|
10
|
+
|
11
|
+
# Check if we should run daily summary (at the start of a new day)
|
12
|
+
if target_hour.hour == 0
|
13
|
+
process_daily_summary(target_hour.to_date - 1.day)
|
14
|
+
|
15
|
+
# Check if we should run weekly summary (Monday at midnight)
|
16
|
+
if target_hour.wday == 1
|
17
|
+
process_weekly_summary((target_hour.to_date - 1.week).beginning_of_week)
|
18
|
+
end
|
19
|
+
|
20
|
+
# Check if we should run monthly summary (first day of month)
|
21
|
+
if target_hour.day == 1
|
22
|
+
process_monthly_summary((target_hour.to_date - 1.month).beginning_of_month)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
rescue => e
|
26
|
+
Rails.logger.error "[RailsPulse] Summary job failed: #{e.message}"
|
27
|
+
Rails.logger.error e.backtrace.join("\n")
|
28
|
+
raise
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
|
33
|
+
def process_hourly_summary(hour)
|
34
|
+
Rails.logger.info "[RailsPulse] Processing hourly summary for #{hour}"
|
35
|
+
SummaryService.new("hour", hour).perform
|
36
|
+
end
|
37
|
+
|
38
|
+
def process_daily_summary(date)
|
39
|
+
Rails.logger.info "[RailsPulse] Processing daily summary for #{date}"
|
40
|
+
SummaryService.new("day", date).perform
|
41
|
+
end
|
42
|
+
|
43
|
+
def process_weekly_summary(week_start)
|
44
|
+
Rails.logger.info "[RailsPulse] Processing weekly summary for week starting #{week_start}"
|
45
|
+
SummaryService.new("week", week_start).perform
|
46
|
+
end
|
47
|
+
|
48
|
+
def process_monthly_summary(month_start)
|
49
|
+
Rails.logger.info "[RailsPulse] Processing monthly summary for month starting #{month_start}"
|
50
|
+
SummaryService.new("month", month_start).perform
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
@@ -8,17 +8,38 @@ module RailsPulse
|
|
8
8
|
end_date = Time.current.to_date
|
9
9
|
date_range = (start_date..end_date)
|
10
10
|
|
11
|
-
# Get the actual data
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
.
|
11
|
+
# Get the actual data from Summary records (routes)
|
12
|
+
summaries = RailsPulse::Summary.where(
|
13
|
+
summarizable_type: "RailsPulse::Route",
|
14
|
+
period_type: "day",
|
15
|
+
period_start: start_date.beginning_of_day..end_date.end_of_day
|
16
|
+
)
|
17
|
+
|
18
|
+
# Group by day manually for cross-database compatibility
|
19
|
+
actual_data = {}
|
20
|
+
summaries.each do |summary|
|
21
|
+
date = summary.period_start.to_date
|
22
|
+
|
23
|
+
if actual_data[date]
|
24
|
+
actual_data[date][:total_weighted] += (summary.avg_duration || 0) * (summary.count || 0)
|
25
|
+
actual_data[date][:total_count] += (summary.count || 0)
|
26
|
+
else
|
27
|
+
actual_data[date] = {
|
28
|
+
total_weighted: (summary.avg_duration || 0) * (summary.count || 0),
|
29
|
+
total_count: (summary.count || 0)
|
30
|
+
}
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
# Convert to final values
|
35
|
+
actual_data = actual_data.transform_values do |data|
|
36
|
+
data[:total_count] > 0 ? (data[:total_weighted] / data[:total_count]).round(0) : 0
|
37
|
+
end
|
16
38
|
|
17
39
|
# Fill in all dates with zero values for missing days
|
18
40
|
date_range.each_with_object({}) do |date, result|
|
19
41
|
formatted_date = date.strftime("%b %-d")
|
20
|
-
|
21
|
-
result[formatted_date] = avg_duration&.round(0) || 0
|
42
|
+
result[formatted_date] = actual_data[date] || 0
|
22
43
|
end
|
23
44
|
end
|
24
45
|
end
|
@@ -3,32 +3,28 @@ module RailsPulse
|
|
3
3
|
module Charts
|
4
4
|
class P95ResponseTime
|
5
5
|
def to_chart_data
|
6
|
-
|
6
|
+
# Create a range of all dates in the past 2 weeks
|
7
|
+
start_date = 2.weeks.ago.beginning_of_day.to_date
|
8
|
+
end_date = Time.current.to_date
|
9
|
+
date_range = (start_date..end_date)
|
7
10
|
|
8
|
-
#
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
.order("request_date, duration")
|
15
|
-
.group_by { |r| r.request_date.to_date }
|
11
|
+
# Get the actual data from Summary records (queries for P95)
|
12
|
+
summaries = RailsPulse::Summary.where(
|
13
|
+
summarizable_type: "RailsPulse::Query",
|
14
|
+
period_type: "day",
|
15
|
+
period_start: start_date.beginning_of_day..end_date.end_of_day
|
16
|
+
)
|
16
17
|
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
p95_value = 0
|
23
|
-
else
|
24
|
-
# Calculate P95 from in-memory sorted array (already sorted by DB)
|
25
|
-
count = day_requests.length
|
26
|
-
p95_index = (count * 0.95).ceil - 1
|
27
|
-
p95_value = day_requests[p95_index].duration.round(0)
|
28
|
-
end
|
18
|
+
actual_data = summaries
|
19
|
+
.group_by_day(:period_start, time_zone: Time.zone)
|
20
|
+
.average(:p95_duration)
|
21
|
+
.transform_keys { |date| date.to_date }
|
22
|
+
.transform_values { |avg| avg&.round(0) || 0 }
|
29
23
|
|
24
|
+
# Fill in all dates with zero values for missing days
|
25
|
+
date_range.each_with_object({}) do |date, result|
|
30
26
|
formatted_date = date.strftime("%b %-d")
|
31
|
-
|
27
|
+
result[formatted_date] = actual_data[date] || 0
|
32
28
|
end
|
33
29
|
end
|
34
30
|
end
|
@@ -8,11 +8,22 @@ module RailsPulse
|
|
8
8
|
this_week_start = 1.week.ago.beginning_of_week
|
9
9
|
this_week_end = Time.current.end_of_week
|
10
10
|
|
11
|
-
# Fetch query data for this week
|
12
|
-
query_data = RailsPulse::
|
13
|
-
.
|
14
|
-
.
|
15
|
-
|
11
|
+
# Fetch query data from Summary records for this week
|
12
|
+
query_data = RailsPulse::Summary
|
13
|
+
.joins("INNER JOIN rails_pulse_queries ON rails_pulse_queries.id = rails_pulse_summaries.summarizable_id")
|
14
|
+
.where(
|
15
|
+
summarizable_type: "RailsPulse::Query",
|
16
|
+
period_type: "day",
|
17
|
+
period_start: this_week_start..this_week_end
|
18
|
+
)
|
19
|
+
.group("rails_pulse_summaries.summarizable_id, rails_pulse_queries.normalized_sql")
|
20
|
+
.select(
|
21
|
+
"rails_pulse_summaries.summarizable_id as query_id",
|
22
|
+
"rails_pulse_queries.normalized_sql",
|
23
|
+
"SUM(rails_pulse_summaries.avg_duration * rails_pulse_summaries.count) / SUM(rails_pulse_summaries.count) as avg_duration",
|
24
|
+
"SUM(rails_pulse_summaries.count) as request_count",
|
25
|
+
"MAX(rails_pulse_summaries.period_end) as last_seen"
|
26
|
+
)
|
16
27
|
.order("avg_duration DESC")
|
17
28
|
.limit(5)
|
18
29
|
|
@@ -20,8 +31,8 @@ module RailsPulse
|
|
20
31
|
data_rows = query_data.map do |record|
|
21
32
|
{
|
22
33
|
query_text: truncate_query(record.normalized_sql),
|
23
|
-
query_id: record.
|
24
|
-
query_link: "/rails_pulse/queries/#{record.
|
34
|
+
query_id: record.query_id,
|
35
|
+
query_link: "/rails_pulse/queries/#{record.query_id}",
|
25
36
|
average_time: record.avg_duration.to_f.round(0),
|
26
37
|
request_count: record.request_count,
|
27
38
|
last_request: time_ago_in_words(record.last_seen)
|
@@ -5,58 +5,51 @@ module RailsPulse
|
|
5
5
|
include RailsPulse::FormattingHelper
|
6
6
|
|
7
7
|
def to_table_data
|
8
|
-
# Get data for this week
|
8
|
+
# Get data for this week
|
9
9
|
this_week_start = 1.week.ago.beginning_of_week
|
10
10
|
this_week_end = Time.current.end_of_week
|
11
|
-
last_week_start = 2.weeks.ago.beginning_of_week
|
12
|
-
last_week_end = 1.week.ago.beginning_of_week
|
13
11
|
|
14
|
-
#
|
15
|
-
|
16
|
-
.
|
17
|
-
.
|
18
|
-
|
12
|
+
# Fetch route data from Summary records for this week
|
13
|
+
route_data = RailsPulse::Summary
|
14
|
+
.joins("INNER JOIN rails_pulse_routes ON rails_pulse_routes.id = rails_pulse_summaries.summarizable_id")
|
15
|
+
.where(
|
16
|
+
summarizable_type: "RailsPulse::Route",
|
17
|
+
period_type: "day",
|
18
|
+
period_start: this_week_start..this_week_end
|
19
|
+
)
|
20
|
+
.group("rails_pulse_summaries.summarizable_id, rails_pulse_routes.path")
|
21
|
+
.select(
|
22
|
+
"rails_pulse_summaries.summarizable_id as route_id",
|
23
|
+
"rails_pulse_routes.path",
|
24
|
+
"SUM(rails_pulse_summaries.avg_duration * rails_pulse_summaries.count) / SUM(rails_pulse_summaries.count) as avg_duration",
|
25
|
+
"SUM(rails_pulse_summaries.count) as request_count",
|
26
|
+
"MAX(rails_pulse_summaries.period_end) as last_seen"
|
27
|
+
)
|
19
28
|
.order("avg_duration DESC")
|
20
29
|
.limit(5)
|
21
30
|
|
22
|
-
#
|
23
|
-
|
24
|
-
.where(occurred_at: last_week_start..last_week_end)
|
25
|
-
.group("rails_pulse_routes.path")
|
26
|
-
.average("rails_pulse_requests.duration")
|
27
|
-
|
28
|
-
# Build result array matching test expectations
|
29
|
-
this_week_data.map do |record|
|
30
|
-
this_week_avg = record.avg_duration.to_f.round(0)
|
31
|
-
last_week_avg = last_week_averages[record.path]&.round(0) || 0
|
32
|
-
|
33
|
-
# Calculate percentage change
|
34
|
-
percentage_change = if last_week_avg == 0
|
35
|
-
this_week_avg > 0 ? 100.0 : 0.0
|
36
|
-
else
|
37
|
-
((this_week_avg - last_week_avg) / last_week_avg.to_f * 100).round(1)
|
38
|
-
end
|
39
|
-
|
40
|
-
# Determine trend (worse = slower response times)
|
41
|
-
trend = if last_week_avg == 0
|
42
|
-
this_week_avg > 0 ? "worse" : "stable"
|
43
|
-
elsif this_week_avg > last_week_avg
|
44
|
-
"worse" # Slower = worse
|
45
|
-
elsif this_week_avg < last_week_avg
|
46
|
-
"better" # Faster = better
|
47
|
-
else
|
48
|
-
"stable"
|
49
|
-
end
|
50
|
-
|
31
|
+
# Build data rows
|
32
|
+
data_rows = route_data.map do |record|
|
51
33
|
{
|
52
34
|
route_path: record.path,
|
53
|
-
|
54
|
-
|
55
|
-
|
35
|
+
route_id: record.route_id,
|
36
|
+
route_link: "/rails_pulse/routes/#{record.route_id}",
|
37
|
+
average_time: record.avg_duration.to_f.round(0),
|
56
38
|
request_count: record.request_count,
|
57
|
-
|
39
|
+
last_request: time_ago_in_words(record.last_seen)
|
58
40
|
}
|
59
41
|
end
|
42
|
+
|
43
|
+
# Return new structure with columns and data
|
44
|
+
{
|
45
|
+
columns: [
|
46
|
+
{ field: :route_path, label: "Route", link_to: :route_link, class: "w-auto" },
|
47
|
+
{ field: :average_time, label: "Average Time", class: "w-32" },
|
48
|
+
{ field: :request_count, label: "Requests", class: "w-24" },
|
49
|
+
{ field: :last_request, label: "Last Request", class: "w-32" }
|
50
|
+
],
|
51
|
+
data: data_rows
|
52
|
+
}
|
60
53
|
end
|
61
54
|
end
|
62
55
|
end
|
@@ -34,7 +34,7 @@ module RailsPulse
|
|
34
34
|
before_validation :associate_query
|
35
35
|
|
36
36
|
def self.ransackable_attributes(auth_object = nil)
|
37
|
-
%w[id occurred_at label duration start_time average_query_time_ms query_count operation_type]
|
37
|
+
%w[id occurred_at label duration start_time average_query_time_ms query_count operation_type query_id]
|
38
38
|
end
|
39
39
|
|
40
40
|
def self.ransackable_associations(auth_object = nil)
|