solid_apm 0.10.0 → 0.11.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 +4 -4
- data/app/assets/javascripts/solid_apm/application.js +19 -0
- data/app/assets/javascripts/solid_apm/time_range_form.js +219 -0
- data/app/assets/stylesheets/solid_apm/application.css +44 -0
- data/app/controllers/solid_apm/transactions_controller.rb +74 -12
- data/app/helpers/solid_apm/application_helper.rb +76 -19
- data/app/views/solid_apm/application/_time_range_form.html.erb +98 -28
- data/app/views/solid_apm/transactions/_charts.html.erb +89 -5
- data/lib/solid_apm/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 96ffe2124d7df431b9e6d3bc95f0ed77d047bf387d1dce1008f5bc654519b7b7
|
4
|
+
data.tar.gz: c4ae53a38929c8eb07e30be80e372c07e0e846165295222857008fc5cf442950
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: d1278425ff0312598f4d75676c5e6ba6eb1e97d6033dfbc45f85e107323aa334a695c75f4118e7d312b28b5640426d6a4725cb3fa8ed37d5d261f9336c349c94
|
7
|
+
data.tar.gz: e0397780ca57f21a8495d02473f67fd0ab3ad9fa60975d6062a13269a07d6a2a1ad87a32c7201f1891f726cbccc7b8282e14e42bd17fe300bf05eac5a8d9cefe
|
@@ -4,6 +4,25 @@ import {
|
|
4
4
|
} from "https://unpkg.com/@hotwired/stimulus/dist/stimulus.js";
|
5
5
|
window.Stimulus = Application.start();
|
6
6
|
|
7
|
+
// Global function for chart selection
|
8
|
+
window.handleChartSelection = function(minTimestamp, maxTimestamp) {
|
9
|
+
const currentUrl = new URL(window.location.href);
|
10
|
+
const params = new URLSearchParams(currentUrl.search);
|
11
|
+
|
12
|
+
// Convert from milliseconds to seconds (ApexCharts provides timestamps in milliseconds)
|
13
|
+
params.set('from_timestamp', Math.floor(minTimestamp / 1000));
|
14
|
+
params.set('to_timestamp', Math.floor(maxTimestamp / 1000));
|
15
|
+
|
16
|
+
// Remove relative time params
|
17
|
+
params.delete('from_value');
|
18
|
+
params.delete('from_unit');
|
19
|
+
params.delete('to_value');
|
20
|
+
params.delete('to_unit');
|
21
|
+
|
22
|
+
// Navigate to the new URL
|
23
|
+
window.location.href = `${currentUrl.pathname}?${params.toString()}`;
|
24
|
+
};
|
25
|
+
|
7
26
|
//= require_tree .
|
8
27
|
|
9
28
|
// require "./controllers/spans-chart_controller"
|
@@ -0,0 +1,219 @@
|
|
1
|
+
class TimeRangeForm {
|
2
|
+
constructor() {
|
3
|
+
this.form = document.getElementById('time-range-form');
|
4
|
+
this.relativeTab = document.getElementById('relative-tab');
|
5
|
+
this.absoluteTab = document.getElementById('absolute-tab');
|
6
|
+
this.relativePanel = document.getElementById('relative-panel');
|
7
|
+
this.absolutePanel = document.getElementById('absolute-panel');
|
8
|
+
this.customFromControl = document.getElementById('custom-from-control');
|
9
|
+
this.customToControl = document.getElementById('custom-to-control');
|
10
|
+
|
11
|
+
// Timezone handling
|
12
|
+
this.browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
13
|
+
this.timezoneOffset = new Date().getTimezoneOffset();
|
14
|
+
|
15
|
+
this.init();
|
16
|
+
}
|
17
|
+
|
18
|
+
init() {
|
19
|
+
this.setupEventListeners();
|
20
|
+
this.initializeFormState();
|
21
|
+
this.addTimezoneToForm();
|
22
|
+
this.adjustAbsoluteTimes();
|
23
|
+
}
|
24
|
+
|
25
|
+
setupEventListeners() {
|
26
|
+
if (this.form) {
|
27
|
+
this.form.addEventListener('submit', (e) => this.handleFormSubmit(e));
|
28
|
+
}
|
29
|
+
}
|
30
|
+
|
31
|
+
switchToRelative(event) {
|
32
|
+
event.preventDefault();
|
33
|
+
|
34
|
+
this.relativeTab.classList.add('is-primary');
|
35
|
+
this.absoluteTab.classList.remove('is-primary');
|
36
|
+
this.relativePanel.classList.remove('is-hidden');
|
37
|
+
this.absolutePanel.classList.add('is-hidden');
|
38
|
+
|
39
|
+
this.removeFields(['from_timestamp', 'to_timestamp']);
|
40
|
+
this.cleanupUrlParams(['from_timestamp', 'to_timestamp', 'from_datetime', 'to_datetime']);
|
41
|
+
}
|
42
|
+
|
43
|
+
switchToAbsolute(event) {
|
44
|
+
event.preventDefault();
|
45
|
+
|
46
|
+
this.absoluteTab.classList.add('is-primary');
|
47
|
+
this.relativeTab.classList.remove('is-primary');
|
48
|
+
this.absolutePanel.classList.remove('is-hidden');
|
49
|
+
this.relativePanel.classList.add('is-hidden');
|
50
|
+
|
51
|
+
this.cleanupUrlParams(['quick_range', 'from_value', 'from_unit', 'to_value', 'to_unit', 'quick_range_apply']);
|
52
|
+
}
|
53
|
+
|
54
|
+
handleQuickRangeChange(select) {
|
55
|
+
const isCustom = select.value === 'custom';
|
56
|
+
|
57
|
+
this.toggleVisibility(this.customFromControl, isCustom);
|
58
|
+
this.toggleVisibility(this.customToControl, isCustom);
|
59
|
+
|
60
|
+
if (!isCustom) {
|
61
|
+
this.applyQuickRange();
|
62
|
+
}
|
63
|
+
}
|
64
|
+
|
65
|
+
applyQuickRange() {
|
66
|
+
const quickRangeSelect = this.form.querySelector('[name="quick_range"]');
|
67
|
+
if (!quickRangeSelect || quickRangeSelect.value === 'custom') return;
|
68
|
+
|
69
|
+
this.removeFields(['from_datetime', 'to_datetime', 'from_timestamp', 'to_timestamp']);
|
70
|
+
this.addHiddenField('quick_range_apply', quickRangeSelect.value);
|
71
|
+
this.form.submit();
|
72
|
+
}
|
73
|
+
|
74
|
+
handleFormSubmit(event) {
|
75
|
+
const isAbsoluteMode = !this.absolutePanel.classList.contains('is-hidden');
|
76
|
+
|
77
|
+
if (isAbsoluteMode) {
|
78
|
+
this.handleAbsoluteModeSubmit();
|
79
|
+
} else {
|
80
|
+
this.handleRelativeModeSubmit();
|
81
|
+
}
|
82
|
+
}
|
83
|
+
|
84
|
+
handleAbsoluteModeSubmit() {
|
85
|
+
const fromDatetime = this.form.querySelector('[name="from_datetime"]');
|
86
|
+
const toDatetime = this.form.querySelector('[name="to_datetime"]');
|
87
|
+
|
88
|
+
if (fromDatetime?.value && toDatetime?.value) {
|
89
|
+
const fromTimestamp = Math.floor(new Date(fromDatetime.value).getTime() / 1000);
|
90
|
+
const toTimestamp = Math.floor(new Date(toDatetime.value).getTime() / 1000);
|
91
|
+
|
92
|
+
fromDatetime.disabled = true;
|
93
|
+
toDatetime.disabled = true;
|
94
|
+
|
95
|
+
this.addHiddenField('from_timestamp', fromTimestamp);
|
96
|
+
this.addHiddenField('to_timestamp', toTimestamp);
|
97
|
+
this.addHiddenField('browser_timezone', this.browserTimezone);
|
98
|
+
this.removeFields(['quick_range', 'from_value', 'from_unit', 'to_value', 'to_unit', 'quick_range_apply']);
|
99
|
+
}
|
100
|
+
}
|
101
|
+
|
102
|
+
handleRelativeModeSubmit() {
|
103
|
+
const quickRangeSelect = this.form.querySelector('[name="quick_range"]');
|
104
|
+
const quickRangeValue = quickRangeSelect?.value;
|
105
|
+
|
106
|
+
if (quickRangeValue && quickRangeValue !== 'custom') {
|
107
|
+
this.removeFields(['from_value', 'from_unit', 'to_value', 'to_unit', 'quick_range_apply']);
|
108
|
+
} else if (quickRangeValue === 'custom') {
|
109
|
+
this.removeFields(['quick_range_apply']);
|
110
|
+
}
|
111
|
+
|
112
|
+
this.removeFields(['from_datetime', 'to_datetime', 'from_timestamp', 'to_timestamp']);
|
113
|
+
}
|
114
|
+
|
115
|
+
initializeFormState() {
|
116
|
+
const urlParams = new URLSearchParams(window.location.search);
|
117
|
+
const hasCustomParams = urlParams.has('from_value') && urlParams.has('from_unit');
|
118
|
+
const hasQuickRange = urlParams.has('quick_range') && urlParams.get('quick_range') !== 'custom';
|
119
|
+
const quickRangeSelect = this.form.querySelector('[name="quick_range"]');
|
120
|
+
|
121
|
+
if (hasQuickRange) {
|
122
|
+
this.toggleVisibility(this.customFromControl, false);
|
123
|
+
this.toggleVisibility(this.customToControl, false);
|
124
|
+
} else if (hasCustomParams || urlParams.get('quick_range') === 'custom') {
|
125
|
+
if (quickRangeSelect) quickRangeSelect.value = 'custom';
|
126
|
+
this.toggleVisibility(this.customFromControl, true);
|
127
|
+
this.toggleVisibility(this.customToControl, true);
|
128
|
+
} else {
|
129
|
+
// Default state - show quick range only
|
130
|
+
this.toggleVisibility(this.customFromControl, false);
|
131
|
+
this.toggleVisibility(this.customToControl, false);
|
132
|
+
}
|
133
|
+
}
|
134
|
+
|
135
|
+
// Utility methods
|
136
|
+
removeFields(fieldNames) {
|
137
|
+
fieldNames.forEach(name => {
|
138
|
+
this.form.querySelectorAll(`[name="${name}"]`).forEach(field => field.remove());
|
139
|
+
});
|
140
|
+
}
|
141
|
+
|
142
|
+
addHiddenField(name, value) {
|
143
|
+
const input = document.createElement('input');
|
144
|
+
input.type = 'hidden';
|
145
|
+
input.name = name;
|
146
|
+
input.value = value;
|
147
|
+
this.form.appendChild(input);
|
148
|
+
}
|
149
|
+
|
150
|
+
toggleVisibility(element, show) {
|
151
|
+
if (!element) return;
|
152
|
+
element.classList.toggle('is-hidden', !show);
|
153
|
+
}
|
154
|
+
|
155
|
+
cleanupUrlParams(params) {
|
156
|
+
const url = new URL(window.location);
|
157
|
+
params.forEach(param => url.searchParams.delete(param));
|
158
|
+
window.history.replaceState({}, '', url);
|
159
|
+
}
|
160
|
+
|
161
|
+
// Timezone-related methods
|
162
|
+
addTimezoneToForm() {
|
163
|
+
// Add timezone information to form for server processing
|
164
|
+
this.addHiddenField('browser_timezone', this.browserTimezone);
|
165
|
+
}
|
166
|
+
|
167
|
+
adjustAbsoluteTimes() {
|
168
|
+
// Convert timestamps from URL to browser timezone for datetime-local inputs
|
169
|
+
const urlParams = new URLSearchParams(window.location.search);
|
170
|
+
const fromTimestamp = urlParams.get('from_timestamp');
|
171
|
+
const toTimestamp = urlParams.get('to_timestamp');
|
172
|
+
|
173
|
+
if (fromTimestamp && toTimestamp) {
|
174
|
+
const fromDatetime = this.form.querySelector('[name="from_datetime"]');
|
175
|
+
const toDatetime = this.form.querySelector('[name="to_datetime"]');
|
176
|
+
|
177
|
+
if (fromDatetime && toDatetime) {
|
178
|
+
// Convert UTC timestamps to local datetime strings
|
179
|
+
const fromDate = new Date(parseInt(fromTimestamp) * 1000);
|
180
|
+
const toDate = new Date(parseInt(toTimestamp) * 1000);
|
181
|
+
|
182
|
+
fromDatetime.value = this.formatDatetimeLocal(fromDate);
|
183
|
+
toDatetime.value = this.formatDatetimeLocal(toDate);
|
184
|
+
}
|
185
|
+
}
|
186
|
+
}
|
187
|
+
|
188
|
+
formatDatetimeLocal(date) {
|
189
|
+
// Format date for datetime-local input (YYYY-MM-DDTHH:MM)
|
190
|
+
const year = date.getFullYear();
|
191
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
192
|
+
const day = String(date.getDate()).padStart(2, '0');
|
193
|
+
const hours = String(date.getHours()).padStart(2, '0');
|
194
|
+
const minutes = String(date.getMinutes()).padStart(2, '0');
|
195
|
+
|
196
|
+
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
197
|
+
}
|
198
|
+
|
199
|
+
}
|
200
|
+
|
201
|
+
// Global functions for onclick handlers (maintaining backward compatibility)
|
202
|
+
let timeRangeFormInstance;
|
203
|
+
|
204
|
+
function switchToRelative(event) {
|
205
|
+
timeRangeFormInstance?.switchToRelative(event);
|
206
|
+
}
|
207
|
+
|
208
|
+
function switchToAbsolute(event) {
|
209
|
+
timeRangeFormInstance?.switchToAbsolute(event);
|
210
|
+
}
|
211
|
+
|
212
|
+
function handleQuickRangeChange(select) {
|
213
|
+
timeRangeFormInstance?.handleQuickRangeChange(select);
|
214
|
+
}
|
215
|
+
|
216
|
+
// Initialize when DOM is ready
|
217
|
+
document.addEventListener('DOMContentLoaded', function() {
|
218
|
+
timeRangeFormInstance = new TimeRangeForm();
|
219
|
+
});
|
@@ -13,3 +13,47 @@
|
|
13
13
|
*= require_tree .
|
14
14
|
*= require_self
|
15
15
|
*/
|
16
|
+
|
17
|
+
/* Time Range Component Enhancements */
|
18
|
+
#time-range-form .box {
|
19
|
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
20
|
+
}
|
21
|
+
|
22
|
+
#time-range-form .tabs ul {
|
23
|
+
border-bottom: 1px solid #dbdbdb;
|
24
|
+
}
|
25
|
+
|
26
|
+
#time-range-form .field.has-addons .control:not(:last-child) .input,
|
27
|
+
#time-range-form .field.has-addons .control:not(:last-child) .select select {
|
28
|
+
border-right: none;
|
29
|
+
}
|
30
|
+
|
31
|
+
#time-range-form .field.has-addons .control:not(:first-child) .input,
|
32
|
+
#time-range-form .field.has-addons .control:not(:first-child) .select select {
|
33
|
+
border-left: none;
|
34
|
+
}
|
35
|
+
|
36
|
+
#time-range-form .label.is-small {
|
37
|
+
font-size: 0.75rem;
|
38
|
+
margin-bottom: 0.25rem;
|
39
|
+
}
|
40
|
+
|
41
|
+
/* Chart Container Enhancements */
|
42
|
+
#charts-container .level {
|
43
|
+
margin-bottom: 0.75rem;
|
44
|
+
}
|
45
|
+
|
46
|
+
#charts-container .tags.has-addons .tag {
|
47
|
+
font-size: 0.75rem;
|
48
|
+
}
|
49
|
+
|
50
|
+
/* Smooth transitions */
|
51
|
+
#relative-panel,
|
52
|
+
#absolute-panel,
|
53
|
+
#custom-relative-range {
|
54
|
+
transition: opacity 0.2s ease-in-out;
|
55
|
+
}
|
56
|
+
|
57
|
+
.is-hidden {
|
58
|
+
display: none !important;
|
59
|
+
}
|
@@ -13,13 +13,12 @@ module SolidApm
|
|
13
13
|
end
|
14
14
|
|
15
15
|
@transactions_scope = Transaction.where(timestamp: from_to_range)
|
16
|
-
if params[:name].present?
|
17
|
-
@transactions_scope = @transactions_scope.where(name: params[:name])
|
18
|
-
end
|
16
|
+
@transactions_scope = @transactions_scope.where(name: params[:name]) if params[:name].present?
|
19
17
|
transaction_names = @transactions_scope.distinct.pluck(:name)
|
20
18
|
latency_95p = @transactions_scope.group(:name).percentile(:duration, 0.95)
|
21
19
|
latency_median = @transactions_scope.group(:name).median(:duration)
|
22
|
-
tmp_dict = @transactions_scope.group(:name).group_by_minute(:timestamp,
|
20
|
+
tmp_dict = @transactions_scope.group(:name).group_by_minute(:timestamp,
|
21
|
+
series: false).count.each_with_object({}) do |(k, v), h|
|
23
22
|
current_value = h[k.first] ||= 0
|
24
23
|
h[k.first] = v if v > current_value
|
25
24
|
end
|
@@ -38,6 +37,7 @@ module SolidApm
|
|
38
37
|
end
|
39
38
|
|
40
39
|
return if @aggregated_transactions.empty?
|
40
|
+
|
41
41
|
# Find the maximum and minimum impact values
|
42
42
|
max_impact = @aggregated_transactions.values.max_by(&:impact).impact
|
43
43
|
min_impact = @aggregated_transactions.values.min_by(&:impact).impact
|
@@ -50,9 +50,13 @@ module SolidApm
|
|
50
50
|
end
|
51
51
|
@aggregated_transactions = @aggregated_transactions.sort_by { |_, v| -v.impact }.to_h
|
52
52
|
|
53
|
-
|
53
|
+
grouping_method, grouping_options = smart_time_grouping(from_to_range)
|
54
|
+
scope = @transactions_scope.public_send(grouping_method, :timestamp, range: from_to_range, **grouping_options)
|
54
55
|
@throughput_data = scope.count
|
55
56
|
@latency_data = scope.median(:duration).transform_values(&:to_i)
|
57
|
+
|
58
|
+
# Pass browser timezone info to view for chart rendering
|
59
|
+
@browser_timezone = params[:browser_timezone]
|
56
60
|
end
|
57
61
|
|
58
62
|
def spans
|
@@ -63,15 +67,71 @@ module SolidApm
|
|
63
67
|
private
|
64
68
|
|
65
69
|
def from_to_range
|
66
|
-
params[:
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
params[:
|
71
|
-
|
70
|
+
if params[:from_timestamp].present? && params[:to_timestamp].present?
|
71
|
+
# Timestamps from browser are already in UTC/local time
|
72
|
+
from = Time.zone.at(params[:from_timestamp].to_i)
|
73
|
+
to = Time.zone.at(params[:to_timestamp].to_i)
|
74
|
+
elsif params[:quick_range_apply].present? || (params[:quick_range].present? && params[:quick_range] != 'custom')
|
75
|
+
to = Time.current
|
76
|
+
quick_range_value = params[:quick_range_apply] || params[:quick_range]
|
77
|
+
from = case quick_range_value
|
78
|
+
when '5m'
|
79
|
+
5.minutes.ago
|
80
|
+
when '15m'
|
81
|
+
15.minutes.ago
|
82
|
+
when '30m'
|
83
|
+
30.minutes.ago
|
84
|
+
when '1h'
|
85
|
+
1.hour.ago
|
86
|
+
when '3h'
|
87
|
+
3.hours.ago
|
88
|
+
when '6h'
|
89
|
+
6.hours.ago
|
90
|
+
when '12h'
|
91
|
+
12.hours.ago
|
92
|
+
when '24h'
|
93
|
+
24.hours.ago
|
94
|
+
when '3d'
|
95
|
+
3.days.ago
|
96
|
+
when '7d'
|
97
|
+
7.days.ago
|
98
|
+
else
|
99
|
+
1.hour.ago
|
100
|
+
end
|
101
|
+
elsif params[:from_value].present? && params[:from_unit].present?
|
102
|
+
from = params[:from_value].to_i.public_send(params[:from_unit].to_sym).ago
|
103
|
+
to_value = params[:to_value].present? ? params[:to_value].to_i : 0
|
104
|
+
to_unit = params[:to_unit].present? ? params[:to_unit] : 'seconds'
|
105
|
+
to = to_value.public_send(to_unit.to_sym).ago
|
106
|
+
else
|
107
|
+
# Default fallback
|
108
|
+
from = 1.hour.ago
|
109
|
+
to = Time.current
|
110
|
+
end
|
72
111
|
(from..to)
|
73
112
|
end
|
74
113
|
|
114
|
+
def smart_time_grouping(range)
|
115
|
+
duration_seconds = (range.end - range.begin).to_i
|
116
|
+
|
117
|
+
case duration_seconds
|
118
|
+
when 0..3600 # 1 hour or less - group by minute
|
119
|
+
[:group_by_minute, {}]
|
120
|
+
when 3601..86_400 # 1-24 hours - group by 15 minutes
|
121
|
+
[:group_by_minute, { n: 15 }]
|
122
|
+
when 86_401..604_800 # 1-7 days - group by hour
|
123
|
+
[:group_by_hour, {}]
|
124
|
+
when 604_801..2_592_000 # 1 week to 1 month - group by 6 hours
|
125
|
+
[:group_by_hour, { n: 6 }]
|
126
|
+
when 2_592_001..7_776_000 # 1-3 months - group by day
|
127
|
+
[:group_by_day, {}]
|
128
|
+
when 7_776_001..31_536_000 # 3 months to 1 year - group by week
|
129
|
+
[:group_by_week, {}]
|
130
|
+
else # more than 1 year - group by month
|
131
|
+
[:group_by_month, {}]
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
75
135
|
def n_intervals_seconds(range, intervals_count: 30)
|
76
136
|
start_time = range.begin
|
77
137
|
end_time = range.end
|
@@ -85,7 +145,9 @@ module SolidApm
|
|
85
145
|
time_range_in_seconds = (end_time - start_time).to_i
|
86
146
|
time_interval_duration_in_seconds = (time_range_in_seconds / intervals_count.to_f).round
|
87
147
|
|
88
|
-
items.chunk
|
148
|
+
items.chunk do |item|
|
149
|
+
Time.zone.at(item.created_at.to_i / time_interval_duration_in_seconds * time_interval_duration_in_seconds, 0)
|
150
|
+
end.to_h
|
89
151
|
end
|
90
152
|
end
|
91
153
|
end
|
@@ -19,33 +19,90 @@ module SolidApm
|
|
19
19
|
end
|
20
20
|
end
|
21
21
|
|
22
|
-
def area_chart_options
|
22
|
+
def area_chart_options(browser_timezone: nil)
|
23
|
+
timezone_js = if browser_timezone.present?
|
24
|
+
"if (!val) return ''; return new Date(val).toLocaleString('en-US', { timeZone: '#{browser_timezone}', hour12: false })"
|
25
|
+
else
|
26
|
+
"if (!val) return ''; return new Date(val).toLocaleString('en-US', { hour12: false })"
|
27
|
+
end
|
28
|
+
|
29
|
+
# Create date formatter for x-axis labels that respects timezone
|
30
|
+
xaxis_formatter = if browser_timezone.present?
|
31
|
+
"if (!val) return ''; return new Date(val).toLocaleString('en-US', { timeZone: '#{browser_timezone}', hour: '2-digit', minute: '2-digit', day: '2-digit', month: 'short', hour12: false })"
|
32
|
+
else
|
33
|
+
"if (!val) return ''; return new Date(val).toLocaleString('en-US', { hour: '2-digit', minute: '2-digit', day: '2-digit', month: 'short', hour12: false })"
|
34
|
+
end
|
35
|
+
|
23
36
|
{
|
24
37
|
module: true,
|
25
38
|
chart: {
|
26
|
-
type: 'area',
|
27
|
-
|
28
|
-
|
29
|
-
|
39
|
+
type: 'area',
|
40
|
+
height: '200',
|
41
|
+
background: '0',
|
42
|
+
foreColor: '#ffffff55',
|
43
|
+
zoom: {
|
44
|
+
enabled: true,
|
45
|
+
autoScaleYaxis: true
|
46
|
+
},
|
47
|
+
selection: {
|
48
|
+
enabled: true,
|
49
|
+
type: 'x'
|
50
|
+
},
|
51
|
+
toolbar: {
|
52
|
+
show: false
|
53
|
+
},
|
54
|
+
events: {
|
55
|
+
zoomed: {
|
56
|
+
function: {
|
57
|
+
args: 'chartContext, { xaxis, yaxis }',
|
58
|
+
body: 'handleChartSelection(xaxis.min, xaxis.max)'
|
59
|
+
}
|
60
|
+
},
|
61
|
+
selection: {
|
62
|
+
function: {
|
63
|
+
args: 'chartContext, { xaxis, yaxis }',
|
64
|
+
body: 'handleChartSelection(xaxis.min, xaxis.max)'
|
65
|
+
}
|
66
|
+
}
|
30
67
|
}
|
31
68
|
},
|
32
69
|
xaxis: {
|
33
|
-
|
34
|
-
|
70
|
+
type: 'datetime',
|
71
|
+
tooltip: {
|
72
|
+
enabled: false
|
73
|
+
},
|
74
|
+
labels: {
|
75
|
+
formatter: {
|
76
|
+
function: {
|
77
|
+
args: 'val',
|
78
|
+
body: xaxis_formatter
|
79
|
+
}
|
80
|
+
}
|
81
|
+
}
|
82
|
+
},
|
83
|
+
stroke: {
|
84
|
+
curve: 'smooth'
|
85
|
+
},
|
86
|
+
theme: {
|
87
|
+
mode: 'dark'
|
88
|
+
},
|
89
|
+
grid: {
|
90
|
+
show: true,
|
91
|
+
borderColor: '#ffffff55'
|
92
|
+
},
|
93
|
+
dataLabels: {
|
35
94
|
enabled: false
|
36
|
-
|
95
|
+
},
|
96
|
+
tooltip: {
|
97
|
+
x: {
|
98
|
+
formatter: {
|
99
|
+
function: {
|
100
|
+
args: 'val',
|
101
|
+
body: timezone_js
|
102
|
+
}
|
103
|
+
}
|
104
|
+
}
|
37
105
|
}
|
38
|
-
},
|
39
|
-
stroke: {
|
40
|
-
curve: 'smooth'
|
41
|
-
}, theme: {
|
42
|
-
mode: 'dark',
|
43
|
-
}, grid: {
|
44
|
-
show: true, borderColor: '#ffffff55',
|
45
|
-
}, dataLabels: {
|
46
|
-
enabled: false
|
47
|
-
},
|
48
|
-
tooltip: {x: {formatter: {function: {args: "val", body: "return new Date(val).toLocaleString()"}} }}
|
49
106
|
}
|
50
107
|
end
|
51
108
|
end
|
@@ -1,34 +1,104 @@
|
|
1
|
-
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
1
|
+
<%
|
2
|
+
is_absolute_mode = params[:from_timestamp].present?
|
3
|
+
is_custom_range = (params[:from_value].present? && params[:from_unit].present?) || params[:quick_range] == 'custom'
|
4
|
+
|
5
|
+
time_ranges = {
|
6
|
+
"Last 5 minutes" => "5m", "Last 15 minutes" => "15m", "Last 30 minutes" => "30m",
|
7
|
+
"Last 1 hour" => "1h", "Last 3 hours" => "3h", "Last 6 hours" => "6h",
|
8
|
+
"Last 12 hours" => "12h", "Last 24 hours" => "24h", "Last 3 days" => "3d",
|
9
|
+
"Last 7 days" => "7d", "Custom" => "custom"
|
10
|
+
}
|
11
|
+
|
12
|
+
time_units = %w[minutes hours days weeks months]
|
13
|
+
to_time_units = %w[seconds minutes hours days weeks]
|
14
|
+
%>
|
15
|
+
|
16
|
+
<%= form_with path: transactions_path, method: :get, local: true, id: 'time-range-form' do |f| %>
|
17
|
+
<div class="field is-grouped is-justify-content-center" style="margin-bottom: 1rem;">
|
18
|
+
<div class="control">
|
19
|
+
<div class="buttons has-addons">
|
20
|
+
<% [['Relative', !is_absolute_mode], ['Absolute', is_absolute_mode]].each do |label, is_active| %>
|
21
|
+
<button type="button"
|
22
|
+
class="button is-small <%= 'is-primary' if is_active %>"
|
23
|
+
id="<%= label.downcase %>-tab"
|
24
|
+
onclick="switchTo<%= label %>(event)">
|
25
|
+
<%= label %>
|
26
|
+
</button>
|
27
|
+
<% end %>
|
28
|
+
</div>
|
13
29
|
</div>
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
30
|
+
|
31
|
+
</div>
|
32
|
+
|
33
|
+
<div id="relative-panel" class="<%= 'is-hidden' if is_absolute_mode %>">
|
34
|
+
<div class="field is-grouped is-grouped-centered is-align-items-end" style="flex-wrap: nowrap;">
|
35
|
+
<div class="control">
|
36
|
+
<div class="select">
|
37
|
+
<%= f.select :quick_range, time_ranges, {
|
38
|
+
selected: params[:quick_range] || (is_custom_range ? 'custom' : '1h'),
|
39
|
+
include_blank: false
|
40
|
+
}, {
|
41
|
+
class: 'select-input',
|
42
|
+
onchange: 'handleQuickRangeChange(this)'
|
43
|
+
} %>
|
44
|
+
</div>
|
45
|
+
</div>
|
46
|
+
|
47
|
+
<% [
|
48
|
+
{ field: :from_value, default: 60, min: 1, units: time_units, default_unit: 'minutes', label: 'From', id: 'custom-from-control' },
|
49
|
+
{ field: :to_value, default: 0, min: 0, units: to_time_units, default_unit: 'seconds', label: 'To', id: 'custom-to-control' }
|
50
|
+
].each do |config| %>
|
51
|
+
<div class="control <%= 'is-hidden' unless is_custom_range %>" id="<%= config[:id] %>">
|
52
|
+
<label class="label is-small"><%= config[:label] %></label>
|
53
|
+
<div class="field has-addons">
|
54
|
+
<div class="control">
|
55
|
+
<%= f.number_field config[:field],
|
56
|
+
value: params[config[:field]] || config[:default],
|
57
|
+
min: config[:min],
|
58
|
+
class: 'input has-text-centered',
|
59
|
+
style: 'width: 3.5em' %>
|
60
|
+
</div>
|
61
|
+
<div class="control">
|
62
|
+
<div class="select">
|
63
|
+
<%= f.select "#{config[:field].to_s.split('_')[0]}_unit", config[:units],
|
64
|
+
{ selected: params["#{config[:field].to_s.split('_')[0]}_unit"] || config[:default_unit] } %>
|
65
|
+
</div>
|
66
|
+
</div>
|
67
|
+
<div class="control">
|
68
|
+
<span class="button is-static">ago</span>
|
69
|
+
</div>
|
70
|
+
</div>
|
71
|
+
</div>
|
72
|
+
<% end %>
|
73
|
+
|
74
|
+
<div class="control">
|
75
|
+
<%= f.submit 'Apply', class: 'button is-primary' %>
|
76
|
+
</div>
|
26
77
|
</div>
|
78
|
+
</div>
|
27
79
|
|
28
|
-
|
29
|
-
<%= f.hidden_field :name, value: params[:name] %>
|
30
|
-
<% end %>
|
80
|
+
<%= f.hidden_field :name, value: params[:name] if params[:name] %>
|
31
81
|
|
32
|
-
|
82
|
+
<div id="absolute-panel" class="<%= 'is-hidden' unless is_absolute_mode %>">
|
83
|
+
<div class="field is-grouped is-grouped-centered is-align-items-end">
|
84
|
+
<% [['from', 1.hour.ago], ['to', Time.current]].each do |prefix, default_time| %>
|
85
|
+
<div class="control">
|
86
|
+
<label class="label is-small">
|
87
|
+
<%= prefix.capitalize %>
|
88
|
+
</label>
|
89
|
+
<%= f.datetime_local_field "#{prefix}_datetime",
|
90
|
+
value: (params["#{prefix}_timestamp"] ?
|
91
|
+
Time.zone.at(params["#{prefix}_timestamp"].to_i).strftime('%Y-%m-%dT%H:%M') :
|
92
|
+
default_time.strftime('%Y-%m-%dT%H:%M')),
|
93
|
+
class: 'input',
|
94
|
+
id: "#{prefix}_datetime" %>
|
95
|
+
</div>
|
96
|
+
<% end %>
|
97
|
+
<div class="control">
|
98
|
+
<%= f.submit 'Apply', class: 'button is-primary' %>
|
99
|
+
</div>
|
100
|
+
</div>
|
33
101
|
</div>
|
34
102
|
<% end %>
|
103
|
+
|
104
|
+
<%= javascript_include_tag 'solid_apm/time_range_form' %>
|
@@ -1,10 +1,94 @@
|
|
1
|
-
<div class="columns pt-2" style="height: 16em">
|
1
|
+
<div class="columns pt-2" style="height: 16em" id="charts-container">
|
2
2
|
<div class="column">
|
3
|
-
<
|
4
|
-
|
3
|
+
<div class="level">
|
4
|
+
<div class="level-left">
|
5
|
+
<div class="level-item">
|
6
|
+
<h2 class="title is-5">Throughput</h2>
|
7
|
+
</div>
|
8
|
+
</div>
|
9
|
+
</div>
|
10
|
+
<div id="throughput-chart">
|
11
|
+
<%= area_chart({ name: 'req/min', data: @throughput_data }, area_chart_options(browser_timezone: @browser_timezone).merge(colors: ['#43BCCD'], refresh: 30)) %>
|
12
|
+
</div>
|
5
13
|
</div>
|
6
14
|
<div class="column">
|
7
|
-
<
|
8
|
-
|
15
|
+
<div class="level">
|
16
|
+
<div class="level-left">
|
17
|
+
<div class="level-item">
|
18
|
+
<h2 class="title is-5">Latency</h2>
|
19
|
+
</div>
|
20
|
+
</div>
|
21
|
+
</div>
|
22
|
+
<div id="latency-chart">
|
23
|
+
<%= area_chart({ name: 'ms', data: @latency_data }, area_chart_options(browser_timezone: @browser_timezone).merge(colors: ['#13d8aa'], refresh: 30)) %>
|
24
|
+
</div>
|
9
25
|
</div>
|
10
26
|
</div>
|
27
|
+
|
28
|
+
<script>
|
29
|
+
// Chart selection functionality for time range
|
30
|
+
function handleChartSelection(minTimestamp, maxTimestamp) {
|
31
|
+
const form = document.getElementById('time-range-form');
|
32
|
+
if (!form) return;
|
33
|
+
|
34
|
+
// Convert chart selection to timestamps
|
35
|
+
const fromTimestamp = Math.floor(minTimestamp / 1000);
|
36
|
+
const toTimestamp = Math.floor(maxTimestamp / 1000);
|
37
|
+
|
38
|
+
// Get browser timezone
|
39
|
+
const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
40
|
+
|
41
|
+
// Create new URL with absolute time range
|
42
|
+
const url = new URL(window.location.href);
|
43
|
+
url.searchParams.set('from_timestamp', fromTimestamp);
|
44
|
+
url.searchParams.set('to_timestamp', toTimestamp);
|
45
|
+
url.searchParams.set('browser_timezone', browserTimezone);
|
46
|
+
|
47
|
+
// Remove relative time parameters
|
48
|
+
['quick_range', 'quick_range_apply', 'from_value', 'from_unit', 'to_value', 'to_unit'].forEach(param => {
|
49
|
+
url.searchParams.delete(param);
|
50
|
+
});
|
51
|
+
|
52
|
+
// Navigate to new URL
|
53
|
+
window.location.href = url.toString();
|
54
|
+
}
|
55
|
+
|
56
|
+
// Chart refresh functionality
|
57
|
+
function refreshCharts() {
|
58
|
+
const form = document.getElementById('time-range-form');
|
59
|
+
if (form) {
|
60
|
+
const formData = new FormData(form);
|
61
|
+
const params = new URLSearchParams(formData);
|
62
|
+
|
63
|
+
fetch('<%= transactions_path %>?' + params.toString(), {
|
64
|
+
headers: {
|
65
|
+
'Accept': 'text/html',
|
66
|
+
'X-Requested-With': 'XMLHttpRequest'
|
67
|
+
}
|
68
|
+
})
|
69
|
+
.then(response => response.text())
|
70
|
+
.then(html => {
|
71
|
+
const parser = new DOMParser();
|
72
|
+
const doc = parser.parseFromString(html, 'text/html');
|
73
|
+
const newChartsContainer = doc.getElementById('charts-container');
|
74
|
+
if (newChartsContainer) {
|
75
|
+
document.getElementById('charts-container').innerHTML = newChartsContainer.innerHTML;
|
76
|
+
}
|
77
|
+
})
|
78
|
+
.catch(error => console.error('Error refreshing charts:', error));
|
79
|
+
}
|
80
|
+
}
|
81
|
+
|
82
|
+
// Auto-refresh charts based on time range
|
83
|
+
document.addEventListener('DOMContentLoaded', function() {
|
84
|
+
const currentUrl = new URL(window.location.href);
|
85
|
+
const hasRecentTimeRange = currentUrl.searchParams.get('quick_range_apply') === '5m' ||
|
86
|
+
currentUrl.searchParams.get('quick_range_apply') === '15m' ||
|
87
|
+
currentUrl.searchParams.get('quick_range_apply') === '30m';
|
88
|
+
|
89
|
+
if (hasRecentTimeRange) {
|
90
|
+
// Auto-refresh every 30 seconds for recent time ranges
|
91
|
+
setInterval(refreshCharts, 30000);
|
92
|
+
}
|
93
|
+
});
|
94
|
+
</script>
|
data/lib/solid_apm/version.rb
CHANGED
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: solid_apm
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.11.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Jean-Francis Bastien
|
@@ -119,6 +119,7 @@ files:
|
|
119
119
|
- Rakefile
|
120
120
|
- app/assets/config/solid_apm_manifest.js
|
121
121
|
- app/assets/javascripts/solid_apm/application.js
|
122
|
+
- app/assets/javascripts/solid_apm/time_range_form.js
|
122
123
|
- app/assets/stylesheets/solid_apm/application.css
|
123
124
|
- app/controllers/solid_apm/application_controller.rb
|
124
125
|
- app/controllers/solid_apm/spans_controller.rb
|