solid_apm 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e3d5be7324da26b5f4720bc0ba9e61717be304286b9ecad84e09adf503095699
4
- data.tar.gz: 8969d758c906bb7de44b4024e1f7353bc63f1f4dda0f2ee5631df1e2fa436e2e
3
+ metadata.gz: d1c1e67891c00e76ce0f09899c23eaef403dd3daceeef92f58795525ee6a91e5
4
+ data.tar.gz: ed1e539e7e0d2d3764dcc45b7ad131a8e6c6fbfc513ed6f80c4869350cab7c17
5
5
  SHA512:
6
- metadata.gz: 1ed2237e30cabd350515555a5e6a66006777835d4e07adcc2dc2758f0fe8e474e931e3da204f44309671923ba1eb1fbcbc32203eaf459a6cebffc2eb3030f3fa
7
- data.tar.gz: fb86bb4e14bf08207255ca5e0121b6e50a5ba43e3a4d394d777362df614d47d25e0d54508a66660c01c7ecccf3c53cc00b395981bee6a32378a5903ca64dd268
6
+ metadata.gz: a803be213c9785533bf87cf6fcb240926d47b63c47c3eff28aa5366349c3906bb9b46e17d7bd2190af3e1a35be2addf8140098f02611ff28b2b414bc85114047
7
+ data.tar.gz: fb982b73450b544990308dbf6de65917708d06826d5f11744b0fcf0123745dde19ee259effdd989273569072956cffe3ea363022b368fcf514a43b94e40e9b59
@@ -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, series: false).count.each_with_object({}) do |(k, v), h|
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
- scope = @transactions_scope.group_by_second(:timestamp, n: n_intervals_seconds(from_to_range))
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[:from_value] ||= 60
67
- params[:from_unit] ||= 'minutes'
68
- from = params[:from_value].to_i.public_send(params[:from_unit].to_sym).ago
69
- params[:to_value] ||= 1
70
- params[:to_unit] ||= 'seconds'
71
- to = params[:to_value].to_i.public_send(params[:to_unit].to_sym).ago
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 { |item| Time.zone.at((item.created_at.to_i) / time_interval_duration_in_seconds * time_interval_duration_in_seconds, 0) }.to_h
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', height: '200', background: '0', foreColor: '#ffffff55', zoom: {
27
- enabled: false,
28
- }, toolbar: {
29
- show: false,
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
- type: 'datetime',
34
- tooltip: {
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
- <%= form_with path: transactions_path, method: :get do |f| %>
2
- <div class="is-flex is-flex-direction-row is-justify-content-center is-align-items-center" style="gap: 1em">
3
- <%= f.number_field :from_value, value: params[:from_value] || 60, min: 1, class: 'input', style: 'width: 6em' %>
4
- <div class="select">
5
- <%= f.select :from_unit, {
6
- "minutes" => "minutes",
7
- "hours" => "hours",
8
- "days" => "days",
9
- "weeks" => "weeks",
10
- "months" => "months",
11
- "years" => "years"
12
- }, {selected: params[:from_unit] || 'minutes' } %>
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
- <b>→</b>
15
- <%= f.number_field :to_value, value: params[:to_value] || 1, min: 1, class: 'input', style: 'width: 6em' %>
16
- <div class="select">
17
- <%= f.select :to_unit, {
18
- "seconds" => "seconds",
19
- "minutes" => "minutes",
20
- "hours" => "hours",
21
- "days" => "days",
22
- "weeks" => "weeks",
23
- "months" => "months",
24
- "years" => "years"
25
- }, {selected: params[:to_unit] || 'seconds' } %>
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
- <% if params[:name] %>
29
- <%= f.hidden_field :name, value: params[:name] %>
30
- <% end %>
80
+ <%= f.hidden_field :name, value: params[:name] if params[:name] %>
31
81
 
32
- <%= f.submit 'Apply', class: 'button' %>
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
- <h2 class="ml-4">Throughput</h2>
4
- <%= area_chart({ name: 'tmp', data: @throughput_data }, area_chart_options.merge(colors: ['#43BCCD'])) %>
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
- <h2 class="ml-4">Latency</h2>
8
- <%= area_chart({ name: 'ms', data: @latency_data }, area_chart_options.merge(colors: ['#13d8aa'])) %>
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>
@@ -1,3 +1,3 @@
1
1
  module SolidApm
2
- VERSION = "0.10.0"
2
+ VERSION = "0.11.1"
3
3
  end
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.10.0
4
+ version: 0.11.1
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