rails_pulse 0.2.3 → 0.2.5.pre.pre.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.
Files changed (122) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +270 -13
  3. data/Rakefile +142 -8
  4. data/app/assets/stylesheets/rails_pulse/components/table.css +16 -1
  5. data/app/assets/stylesheets/rails_pulse/components/tags.css +7 -2
  6. data/app/assets/stylesheets/rails_pulse/components/utilities.css +3 -0
  7. data/app/controllers/concerns/chart_table_concern.rb +3 -3
  8. data/app/controllers/rails_pulse/application_controller.rb +20 -3
  9. data/app/controllers/rails_pulse/assets_controller.rb +18 -2
  10. data/app/controllers/rails_pulse/job_runs_controller.rb +37 -0
  11. data/app/controllers/rails_pulse/jobs_controller.rb +80 -0
  12. data/app/controllers/rails_pulse/operations_controller.rb +43 -31
  13. data/app/controllers/rails_pulse/queries_controller.rb +1 -1
  14. data/app/controllers/rails_pulse/requests_controller.rb +3 -8
  15. data/app/controllers/rails_pulse/routes_controller.rb +1 -1
  16. data/app/controllers/rails_pulse/tags_controller.rb +31 -5
  17. data/app/helpers/rails_pulse/application_helper.rb +79 -3
  18. data/app/helpers/rails_pulse/breadcrumbs_helper.rb +15 -1
  19. data/app/helpers/rails_pulse/chart_helper.rb +32 -2
  20. data/app/helpers/rails_pulse/status_helper.rb +16 -0
  21. data/app/helpers/rails_pulse/tags_helper.rb +39 -1
  22. data/app/javascript/rails_pulse/application.js +3 -54
  23. data/app/javascript/rails_pulse/controllers/chart_controller.js +333 -0
  24. data/app/javascript/rails_pulse/controllers/index_controller.js +9 -14
  25. data/app/javascript/rails_pulse/controllers/pagination_controller.js +27 -33
  26. data/app/jobs/rails_pulse/backfill_summaries_job.rb +0 -2
  27. data/app/jobs/rails_pulse/cleanup_job.rb +0 -2
  28. data/app/jobs/rails_pulse/summary_job.rb +0 -2
  29. data/app/models/concerns/rails_pulse/taggable.rb +25 -2
  30. data/app/models/rails_pulse/charts/operations_chart.rb +33 -0
  31. data/app/models/rails_pulse/dashboard/charts/p95_response_time.rb +1 -2
  32. data/app/models/rails_pulse/dashboard/tables/slow_routes.rb +1 -1
  33. data/app/models/rails_pulse/job.rb +85 -0
  34. data/app/models/rails_pulse/job_run.rb +76 -0
  35. data/app/models/rails_pulse/jobs/cards/average_duration.rb +85 -0
  36. data/app/models/rails_pulse/jobs/cards/base.rb +70 -0
  37. data/app/models/rails_pulse/jobs/cards/failure_rate.rb +85 -0
  38. data/app/models/rails_pulse/jobs/cards/total_jobs.rb +74 -0
  39. data/app/models/rails_pulse/jobs/cards/total_runs.rb +48 -0
  40. data/app/models/rails_pulse/operation.rb +16 -3
  41. data/app/models/rails_pulse/queries/cards/average_query_times.rb +3 -3
  42. data/app/models/rails_pulse/queries/cards/execution_rate.rb +1 -1
  43. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +1 -1
  44. data/app/models/rails_pulse/queries/charts/average_query_times.rb +1 -1
  45. data/app/models/rails_pulse/queries/tables/index.rb +2 -1
  46. data/app/models/rails_pulse/query.rb +10 -1
  47. data/app/models/rails_pulse/requests/charts/average_response_times.rb +1 -1
  48. data/app/models/rails_pulse/routes/cards/average_response_times.rb +3 -2
  49. data/app/models/rails_pulse/routes/cards/error_rate_per_route.rb +1 -1
  50. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +1 -1
  51. data/app/models/rails_pulse/routes/cards/request_count_totals.rb +1 -1
  52. data/app/models/rails_pulse/routes/charts/average_response_times.rb +1 -1
  53. data/app/models/rails_pulse/routes/tables/index.rb +2 -1
  54. data/app/models/rails_pulse/summary.rb +10 -3
  55. data/app/services/rails_pulse/summary_service.rb +46 -0
  56. data/app/views/layouts/rails_pulse/_menu_items.html.erb +7 -0
  57. data/app/views/layouts/rails_pulse/application.html.erb +23 -0
  58. data/app/views/rails_pulse/components/_active_filters.html.erb +7 -6
  59. data/app/views/rails_pulse/components/_metric_card.html.erb +2 -2
  60. data/app/views/rails_pulse/components/_page_header.html.erb +8 -7
  61. data/app/views/rails_pulse/components/_sparkline_stats.html.erb +1 -1
  62. data/app/views/rails_pulse/components/_table.html.erb +7 -4
  63. data/app/views/rails_pulse/components/_table_pagination.html.erb +8 -6
  64. data/app/views/rails_pulse/csp_test/show.html.erb +1 -1
  65. data/app/views/rails_pulse/dashboard/charts/_bar_chart.html.erb +1 -1
  66. data/app/views/rails_pulse/dashboard/index.html.erb +5 -4
  67. data/app/views/rails_pulse/job_runs/_operations.html.erb +78 -0
  68. data/app/views/rails_pulse/job_runs/index.html.erb +3 -0
  69. data/app/views/rails_pulse/job_runs/show.html.erb +51 -0
  70. data/app/views/rails_pulse/jobs/_job_runs_table.html.erb +35 -0
  71. data/app/views/rails_pulse/jobs/_table.html.erb +43 -0
  72. data/app/views/rails_pulse/jobs/index.html.erb +34 -0
  73. data/app/views/rails_pulse/jobs/show.html.erb +49 -0
  74. data/app/views/rails_pulse/operations/_operation_analysis_application.html.erb +29 -27
  75. data/app/views/rails_pulse/operations/_operation_analysis_view.html.erb +11 -9
  76. data/app/views/rails_pulse/operations/show.html.erb +10 -8
  77. data/app/views/rails_pulse/queries/_table.html.erb +3 -3
  78. data/app/views/rails_pulse/queries/index.html.erb +2 -1
  79. data/app/views/rails_pulse/queries/show.html.erb +2 -1
  80. data/app/views/rails_pulse/requests/_table.html.erb +6 -6
  81. data/app/views/rails_pulse/routes/_table.html.erb +3 -3
  82. data/app/views/rails_pulse/routes/index.html.erb +2 -1
  83. data/app/views/rails_pulse/routes/show.html.erb +3 -2
  84. data/app/views/rails_pulse/tags/_tag_manager.html.erb +7 -14
  85. data/config/brakeman.ignore +213 -0
  86. data/config/brakeman.yml +68 -0
  87. data/config/importmap.rb +1 -1
  88. data/config/initializers/rails_pulse.rb +52 -0
  89. data/config/routes.rb +6 -0
  90. data/db/rails_pulse_migrate/20250113000000_add_jobs_to_rails_pulse.rb +95 -0
  91. data/db/rails_pulse_migrate/20250122000000_add_query_fingerprinting.rb +150 -0
  92. data/db/rails_pulse_migrate/20250202000000_add_index_to_request_uuid.rb +14 -0
  93. data/db/rails_pulse_schema.rb +186 -103
  94. data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +186 -103
  95. data/lib/generators/rails_pulse/templates/migrations/install_rails_pulse_tables.rb +30 -1
  96. data/lib/generators/rails_pulse/templates/rails_pulse.rb +31 -0
  97. data/lib/rails_pulse/active_job_extensions.rb +13 -0
  98. data/lib/rails_pulse/adapters/delayed_job_plugin.rb +25 -0
  99. data/lib/rails_pulse/adapters/sidekiq_middleware.rb +41 -0
  100. data/lib/rails_pulse/cleanup_service.rb +65 -0
  101. data/lib/rails_pulse/configuration.rb +80 -7
  102. data/lib/rails_pulse/engine.rb +29 -28
  103. data/lib/rails_pulse/extensions/active_record.rb +82 -0
  104. data/lib/rails_pulse/job_run_collector.rb +172 -0
  105. data/lib/rails_pulse/middleware/request_collector.rb +20 -43
  106. data/lib/rails_pulse/subscribers/operation_subscriber.rb +11 -5
  107. data/lib/rails_pulse/tracker.rb +82 -0
  108. data/lib/rails_pulse/version.rb +1 -1
  109. data/lib/rails_pulse.rb +2 -0
  110. data/lib/rails_pulse_server.ru +107 -0
  111. data/lib/tasks/rails_pulse_benchmark.rake +382 -0
  112. data/public/rails-pulse-assets/csp-test.js +10 -10
  113. data/public/rails-pulse-assets/rails-pulse-icons.js +3 -2
  114. data/public/rails-pulse-assets/rails-pulse-icons.js.map +1 -1
  115. data/public/rails-pulse-assets/rails-pulse.css +1 -1
  116. data/public/rails-pulse-assets/rails-pulse.css.map +1 -1
  117. data/public/rails-pulse-assets/rails-pulse.js +48 -48
  118. data/public/rails-pulse-assets/rails-pulse.js.map +4 -4
  119. metadata +38 -30
  120. data/app/models/rails_pulse/requests/charts/operations_chart.rb +0 -35
  121. data/config/initializers/rails_charts_csp_patch.rb +0 -75
  122. data/db/migrate/20250930105043_install_rails_pulse_tables.rb +0 -23
@@ -1,5 +1,43 @@
1
1
  module RailsPulse
2
2
  module TagsHelper
3
+ # Render a single tag badge
4
+ # Options:
5
+ # - variant: :default (no class), :secondary, :positive
6
+ # - removable: boolean - whether to include a remove button
7
+ # - taggable_type: string - type of taggable object (for remove button)
8
+ # - taggable_id: integer - id of taggable object (for remove button)
9
+ def render_tag_badge(tag, variant: :default, removable: false, taggable_type: nil, taggable_id: nil)
10
+ badge_class = case variant
11
+ when :secondary
12
+ "badge badge--secondary font-normal"
13
+ when :positive
14
+ "badge badge--positive font-normal"
15
+ else
16
+ "badge font-normal"
17
+ end
18
+
19
+ if removable && taggable_type && taggable_id
20
+ # For removable tags, render the full structure with button_to
21
+ content_tag(:span, class: badge_class) do
22
+ concat tag.humanize
23
+ concat " "
24
+ concat(
25
+ button_to(
26
+ remove_tag_path(taggable_type, taggable_id, tag: tag),
27
+ method: :delete,
28
+ class: "tag-remove",
29
+ data: { turbo_frame: "_top" }
30
+ ) do
31
+ content_tag(:span, "×", "aria-hidden": "true")
32
+ end
33
+ )
34
+ end
35
+ else
36
+ # For non-removable tags, just render the badge
37
+ content_tag(:span, tag, class: badge_class)
38
+ end
39
+ end
40
+
3
41
  # Display tags as badge elements
4
42
  # Accepts:
5
43
  # - Taggable objects (with tag_list method)
@@ -23,7 +61,7 @@ module RailsPulse
23
61
 
24
62
  return content_tag(:span, "-", class: "text-subtle") if tag_array.empty?
25
63
 
26
- safe_join(tag_array.map { |tag| content_tag(:div, tag, class: "badge") }, " ")
64
+ safe_join(tag_array.map { |tag| content_tag(:div, tag.humanize, class: "badge") }, " ")
27
65
  end
28
66
  end
29
67
  end
@@ -12,6 +12,7 @@ import PopoverController from "./controllers/popover_controller";
12
12
  import FormController from "./controllers/form_controller";
13
13
 
14
14
  // Rails Pulse Controllers
15
+ import ChartController from "./controllers/chart_controller";
15
16
  import IndexController from "./controllers/index_controller";
16
17
  import ColorSchemeController from "./controllers/color_scheme_controller";
17
18
  import PaginationController from "./controllers/pagination_controller";
@@ -29,7 +30,7 @@ const application = Application.start();
29
30
  application.debug = false;
30
31
  window.Stimulus = application;
31
32
 
32
- // Make ECharts available globally for rails_charts gem
33
+ // Make ECharts available globally for chart rendering
33
34
  window.echarts = echarts;
34
35
 
35
36
  // Make Turbo available globally
@@ -42,6 +43,7 @@ application.register("rails-pulse--menu", MenuController);
42
43
  application.register("rails-pulse--popover", PopoverController);
43
44
  application.register("rails-pulse--form", FormController);
44
45
 
46
+ application.register("rails-pulse--chart", ChartController);
45
47
  application.register("rails-pulse--index", IndexController);
46
48
  application.register("rails-pulse--color-scheme", ColorSchemeController);
47
49
  application.register("rails-pulse--pagination", PaginationController);
@@ -96,59 +98,6 @@ echarts.registerTheme('railspulse', {
96
98
  "bar": { "itemStyle": { "barBorderWidth": 0 } }
97
99
  });
98
100
 
99
- // Chart resize functionality (moved from inline script for CSP compliance)
100
- window.addEventListener('resize', function() {
101
- if (window.RailsCharts && window.RailsCharts.charts) {
102
- Object.keys(window.RailsCharts.charts).forEach(function(chartID) {
103
- window.RailsCharts.charts[chartID].resize();
104
- });
105
- }
106
- });
107
-
108
- // Apply axis label colors based on current color scheme
109
- function applyChartAxisLabelColors() {
110
- if (!window.RailsCharts || !window.RailsCharts.charts) return;
111
- const scheme = document.documentElement.getAttribute('data-color-scheme');
112
- const isDark = scheme === 'dark';
113
- const axisColor = isDark ? '#ffffff' : '#999999';
114
- Object.keys(window.RailsCharts.charts).forEach(function(chartID) {
115
- const chart = window.RailsCharts.charts[chartID];
116
- try {
117
- chart.setOption({
118
- xAxis: { axisLabel: { color: axisColor } },
119
- yAxis: { axisLabel: { color: axisColor } }
120
- });
121
- } catch (e) {
122
- // noop
123
- }
124
- });
125
- }
126
-
127
- // Initial apply after charts initialize and on scheme changes
128
- document.addEventListener('DOMContentLoaded', () => {
129
- // run shortly after load to allow charts to initialize
130
- setTimeout(applyChartAxisLabelColors, 50);
131
- });
132
- document.addEventListener('rails-pulse:color-scheme-changed', applyChartAxisLabelColors);
133
-
134
- // Global function to initialize Rails Charts in any container.
135
- // This is needed as we render Rails Charts in Turbo Frames.
136
- window.initializeChartsInContainer = function(containerId) {
137
- requestAnimationFrame(() => {
138
- const container = containerId ? document.getElementById(containerId) : document;
139
- const scripts = container.querySelectorAll('script');
140
- scripts.forEach(script => {
141
- const content = script.textContent;
142
- const match = content.match(/function\s+(init_rails_charts_[a-f0-9]+)/);
143
- if (match && window[match[1]]) {
144
- window[match[1]]();
145
- }
146
- });
147
- // ensure colors are correct for any charts initialized in this container
148
- setTimeout(applyChartAxisLabelColors, 10);
149
- });
150
- };
151
-
152
101
  // Export for global access
153
102
  window.RailsPulse = {
154
103
  application,
@@ -0,0 +1,333 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static values = {
5
+ type: String, // "bar", "line", "area", "sparkline"
6
+ data: Object, // Chart data
7
+ options: Object, // ECharts configuration
8
+ theme: String // ECharts theme
9
+ }
10
+
11
+ connect() {
12
+ this.initializeChart()
13
+ this.handleColorSchemeChange = this.onColorSchemeChange.bind(this)
14
+ document.addEventListener('rails-pulse:color-scheme-changed', this.handleColorSchemeChange)
15
+ }
16
+
17
+ disconnect() {
18
+ document.removeEventListener('rails-pulse:color-scheme-changed', this.handleColorSchemeChange)
19
+ this.disposeChart()
20
+ }
21
+
22
+ // Main initialization with retry logic
23
+ initializeChart() {
24
+ this.retryCount = 0
25
+ this.maxRetries = 100 // 5 seconds
26
+ this.attemptInit()
27
+ }
28
+
29
+ attemptInit() {
30
+ if (typeof echarts === 'undefined') {
31
+ this.retryCount++
32
+ if (this.retryCount >= this.maxRetries) {
33
+ console.error('[RailsPulse] echarts not loaded after 5 seconds for', this.element.id)
34
+ this.showError()
35
+ return
36
+ }
37
+ setTimeout(() => this.attemptInit(), 50)
38
+ return
39
+ }
40
+
41
+ this.renderChart()
42
+ }
43
+
44
+ renderChart() {
45
+ try {
46
+ // Initialize chart
47
+ this.chart = echarts.init(this.element, this.themeValue || 'railspulse')
48
+
49
+ // Build and set options
50
+ const config = this.buildChartConfig()
51
+ this.chart.setOption(config)
52
+
53
+ // Apply current color scheme
54
+ this.applyColorScheme()
55
+
56
+ // Dispatch event for other controllers (event-based communication)
57
+ document.dispatchEvent(new CustomEvent('stimulus:echarts:rendered', {
58
+ detail: {
59
+ containerId: this.element.id,
60
+ chart: this.chart,
61
+ controller: this
62
+ }
63
+ }))
64
+
65
+ // Responsive resize
66
+ this.resizeObserver = new ResizeObserver(() => {
67
+ if (this.chart) {
68
+ this.chart.resize()
69
+ }
70
+ })
71
+ this.resizeObserver.observe(this.element)
72
+
73
+ // Mark as rendered for tests
74
+ this.element.setAttribute('data-chart-rendered', 'true')
75
+
76
+ } catch (error) {
77
+ console.error('[RailsPulse] Error initializing chart:', error)
78
+ this.showError()
79
+ }
80
+ }
81
+
82
+ buildChartConfig() {
83
+ // Start with provided options
84
+ const config = { ...this.optionsValue }
85
+
86
+ // Process formatters (convert function strings to actual functions)
87
+ this.processFormatters(config)
88
+
89
+ // Set data (xAxis and series)
90
+ this.setChartData(config)
91
+
92
+ return config
93
+ }
94
+
95
+ setChartData(config) {
96
+ const data = this.dataValue
97
+
98
+ // Extract labels and values
99
+ const labels = Object.keys(data).map(k => {
100
+ const num = Number(k)
101
+ return isNaN(num) ? k : num
102
+ })
103
+
104
+ const values = Object.values(data).map(v => {
105
+ if (typeof v === 'object' && v !== null) {
106
+ return v.value !== undefined ? v.value : v
107
+ }
108
+ return v
109
+ })
110
+
111
+ // Set xAxis data
112
+ config.xAxis = config.xAxis || {}
113
+ config.xAxis.type = 'category'
114
+ config.xAxis.data = labels
115
+
116
+ // Set yAxis
117
+ config.yAxis = config.yAxis || {}
118
+ config.yAxis.type = 'value'
119
+
120
+ // Set series data
121
+ if (Array.isArray(config.series)) {
122
+ // If series is already an array, update first series
123
+ config.series[0] = config.series[0] || {}
124
+ config.series[0].type = this.typeValue
125
+ config.series[0].data = values
126
+ } else if (config.series && typeof config.series === 'object') {
127
+ // If series is a single object (from helper), convert to array
128
+ const seriesConfig = { ...config.series }
129
+ config.series = [{
130
+ type: this.typeValue,
131
+ data: values,
132
+ ...seriesConfig
133
+ }]
134
+ } else {
135
+ // No series provided, create default
136
+ config.series = [{
137
+ type: this.typeValue,
138
+ data: values
139
+ }]
140
+ }
141
+ }
142
+
143
+ processFormatters(config) {
144
+ // Process tooltip formatter
145
+ if (config.tooltip?.formatter && typeof config.tooltip.formatter === 'string') {
146
+ config.tooltip.formatter = this.parseFormatter(config.tooltip.formatter)
147
+ }
148
+
149
+ // Process xAxis formatter
150
+ if (config.xAxis?.axisLabel?.formatter && typeof config.xAxis.axisLabel.formatter === 'string') {
151
+ config.xAxis.axisLabel.formatter = this.parseFormatter(config.xAxis.axisLabel.formatter)
152
+ }
153
+
154
+ // Process yAxis formatter
155
+ if (config.yAxis?.axisLabel?.formatter && typeof config.yAxis.axisLabel.formatter === 'string') {
156
+ config.yAxis.axisLabel.formatter = this.parseFormatter(config.yAxis.axisLabel.formatter)
157
+ }
158
+ }
159
+
160
+ parseFormatter(formatterString) {
161
+ // Remove function markers if present
162
+ const cleanString = formatterString.replace(/__FUNCTION_START__|__FUNCTION_END__/g, '')
163
+
164
+ // If it's a function string, use safe formatter registry instead of eval()
165
+ if (cleanString.trim().startsWith('function')) {
166
+ // Extract formatter logic using safe parsing
167
+ // Rather than eval(), we match against known safe patterns
168
+ return this.getSafeFormatter(cleanString)
169
+ }
170
+ return cleanString
171
+ }
172
+
173
+ /**
174
+ * Returns a safe formatter function based on the formatter string.
175
+ * This prevents arbitrary code execution by using a whitelist approach.
176
+ *
177
+ * Security: Replaces eval() to prevent XSS and code injection attacks.
178
+ */
179
+ getSafeFormatter(formatterString) {
180
+ // Whitelist of safe formatter patterns
181
+ // Each pattern maps to a safe implementation
182
+ const SAFE_FORMATTERS = {
183
+ // Duration formatter (milliseconds)
184
+ 'duration_ms': (value) => {
185
+ if (typeof value === 'number') {
186
+ return value.toFixed(2) + ' ms'
187
+ }
188
+ return value
189
+ },
190
+
191
+ // Percentage formatter
192
+ 'percentage': (value) => {
193
+ if (typeof value === 'number') {
194
+ return value.toFixed(1) + '%'
195
+ }
196
+ return value
197
+ },
198
+
199
+ // Number with commas
200
+ 'number_delimited': (value) => {
201
+ if (typeof value === 'number') {
202
+ return value.toLocaleString()
203
+ }
204
+ return value
205
+ },
206
+
207
+ // Timestamp formatter
208
+ 'timestamp': (value) => {
209
+ if (typeof value === 'number' || typeof value === 'string') {
210
+ const date = new Date(value)
211
+ return date.toLocaleString()
212
+ }
213
+ return value
214
+ },
215
+
216
+ // Date only
217
+ 'date': (value) => {
218
+ if (typeof value === 'number' || typeof value === 'string') {
219
+ const date = new Date(value)
220
+ return date.toLocaleDateString()
221
+ }
222
+ return value
223
+ },
224
+
225
+ // Time only
226
+ 'time': (value) => {
227
+ if (typeof value === 'number' || typeof value === 'string') {
228
+ const date = new Date(value)
229
+ return date.toLocaleTimeString()
230
+ }
231
+ return value
232
+ },
233
+
234
+ // Bytes formatter
235
+ 'bytes': (value) => {
236
+ if (typeof value !== 'number') return value
237
+
238
+ const units = ['B', 'KB', 'MB', 'GB', 'TB']
239
+ let size = value
240
+ let unitIndex = 0
241
+
242
+ while (size >= 1024 && unitIndex < units.length - 1) {
243
+ size /= 1024
244
+ unitIndex++
245
+ }
246
+
247
+ return size.toFixed(2) + ' ' + units[unitIndex]
248
+ }
249
+ }
250
+
251
+ // Try to match the formatter string to a known safe pattern
252
+ for (const [key, formatter] of Object.entries(SAFE_FORMATTERS)) {
253
+ if (formatterString.includes(key) ||
254
+ formatterString.includes(key.replace('_', ''))) {
255
+ return formatter
256
+ }
257
+ }
258
+
259
+ // Check for specific safe patterns in the function string
260
+ if (formatterString.includes('toFixed(2)') && formatterString.includes('ms')) {
261
+ return SAFE_FORMATTERS.duration_ms
262
+ }
263
+
264
+ if (formatterString.includes('toLocaleString')) {
265
+ return SAFE_FORMATTERS.number_delimited
266
+ }
267
+
268
+ if (formatterString.includes('toLocaleDateString')) {
269
+ return SAFE_FORMATTERS.date
270
+ }
271
+
272
+ if (formatterString.includes('toLocaleTimeString')) {
273
+ return SAFE_FORMATTERS.time
274
+ }
275
+
276
+ // Default: return a safe identity function that just returns the value
277
+ console.warn('[RailsPulse] Unknown formatter pattern, using identity function:', formatterString)
278
+ return (value) => value
279
+ }
280
+
281
+ showError() {
282
+ this.element.classList.add('chart-error')
283
+ this.element.innerHTML = '<p class="text-subtle p-4">Chart failed to load</p>'
284
+ }
285
+
286
+ // Public accessor for chart instance
287
+ get chartInstance() {
288
+ return this.chart
289
+ }
290
+
291
+ disposeChart() {
292
+ if (this.resizeObserver) {
293
+ this.resizeObserver.disconnect()
294
+ }
295
+
296
+ if (this.chart) {
297
+ this.chart.dispose()
298
+ this.chart = null
299
+ }
300
+ }
301
+
302
+ // Action for dynamic updates
303
+ update(event) {
304
+ if (event.detail?.data) {
305
+ this.dataValue = event.detail.data
306
+ }
307
+ if (event.detail?.options) {
308
+ this.optionsValue = event.detail.options
309
+ }
310
+ if (this.chart) {
311
+ const config = this.buildChartConfig()
312
+ this.chart.setOption(config, true) // true = not merge
313
+ }
314
+ }
315
+
316
+ // Color scheme management
317
+ onColorSchemeChange() {
318
+ this.applyColorScheme()
319
+ }
320
+
321
+ applyColorScheme() {
322
+ if (!this.chart) return
323
+
324
+ const scheme = document.documentElement.getAttribute('data-color-scheme')
325
+ const isDark = scheme === 'dark'
326
+ const axisColor = isDark ? '#ffffff' : '#999999'
327
+
328
+ this.chart.setOption({
329
+ xAxis: { axisLabel: { color: axisColor } },
330
+ yAxis: { axisLabel: { color: axisColor } }
331
+ })
332
+ }
333
+ }
@@ -15,21 +15,16 @@ export default class extends Controller {
15
15
  originalSeriesOption = null;
16
16
 
17
17
  connect() {
18
- // Listen for the custom event 'chart:initialized' to set up the chart.
19
- // This event is sent from the RailsCharts library when the chart is ready.
18
+ // Listen for the custom event 'stimulus:echarts:rendered' to set up the chart.
19
+ // This event is dispatched by the chart controller when the chart is ready.
20
20
  this.handleChartInitialized = this.onChartInitialized.bind(this);
21
21
 
22
- document.addEventListener('chart:rendered', this.handleChartInitialized);
23
-
24
- // If the chart is already initialized (e.g., on back navigation), set up immediately
25
- if (window.RailsCharts?.charts?.[this.chartIdValue]) {
26
- this.setup();
27
- }
22
+ document.addEventListener('stimulus:echarts:rendered', this.handleChartInitialized);
28
23
  }
29
24
 
30
25
  disconnect() {
31
- // Remove the event listener from RailsCharts when the controller is disconnected
32
- document.removeEventListener('chart:rendered', this.handleChartInitialized);
26
+ // Remove the event listener when the controller is disconnected
27
+ document.removeEventListener('stimulus:echarts:rendered', this.handleChartInitialized);
33
28
 
34
29
  // Remove chart event listeners if they exist
35
30
  if (this.hasChartTarget && this.chartTarget) {
@@ -47,6 +42,8 @@ export default class extends Controller {
47
42
  // After the chart is initialized, set up the event listeners and data tracking
48
43
  onChartInitialized(event) {
49
44
  if (event.detail.containerId === this.chartIdValue) {
45
+ // Store the chart instance from the event
46
+ this.chart = event.detail.chart;
50
47
  this.setup();
51
48
  }
52
49
  }
@@ -56,7 +53,7 @@ export default class extends Controller {
56
53
  return; // Prevent multiple setups
57
54
  }
58
55
 
59
- // We need both the chart target in DOM and the chart object from RailsCharts
56
+ // We need both the chart target in DOM and the chart object from the event
60
57
  let hasTarget = false;
61
58
  try {
62
59
  hasTarget = !!this.chartTarget;
@@ -64,10 +61,8 @@ export default class extends Controller {
64
61
  hasTarget = false;
65
62
  }
66
63
 
67
- // Get the chart element which the RailsCharts library has created
68
- this.chart = window.RailsCharts.charts[this.chartIdValue];
69
-
70
64
  // Only proceed if we have BOTH the DOM target and the chart object
65
+ // (chart is set by onChartInitialized from the event)
71
66
  if (!hasTarget || !this.chart) {
72
67
  return;
73
68
  }
@@ -3,56 +3,50 @@ import { Controller } from "@hotwired/stimulus"
3
3
  export default class extends Controller {
4
4
  static targets = ["limit"]
5
5
  static values = {
6
- storageKey: { type: String, default: "rails_pulse_pagination_limit" },
7
- url: String
6
+ storageKey: { type: String, default: "rails_pulse_pagination_limit" }
8
7
  }
9
8
 
10
9
  connect() {
11
10
  this.restorePaginationLimit()
12
11
  }
13
12
 
14
- // Update pagination limit and refresh the turbo frame
13
+ // Update pagination limit and navigate to page 1 with new limit
15
14
  updateLimit() {
16
15
  const limit = this.limitTarget.value
17
16
 
18
- // Save to session storage only - no server request needed
17
+ // Save to session storage for persistence
19
18
  sessionStorage.setItem(this.storageKeyValue, limit)
20
19
 
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
20
+ // Build URL with limit parameter and reset to page 1
21
+ const currentUrl = new URL(window.location)
22
+ currentUrl.searchParams.set('limit', limit)
23
+ currentUrl.searchParams.delete('page')
24
+
25
+ // Use Turbo.visit for smooth navigation that preserves query params
26
+ if (typeof Turbo !== 'undefined') {
27
+ Turbo.visit(currentUrl.toString(), { action: 'replace' })
28
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
29
+ window.location.href = currentUrl.toString()
33
30
  }
34
31
  }
35
32
 
36
- // Get CSRF token from meta tag
37
- getCSRFToken() {
38
- const token = document.querySelector('meta[name="csrf-token"]')
39
- return token ? token.getAttribute('content') : ''
40
- }
41
-
42
- // Save the pagination limit to session storage when it changes
43
- savePaginationLimit() {
44
- const limit = this.limitTarget.value
45
- sessionStorage.setItem(this.storageKeyValue, limit)
46
- }
47
-
48
- // Restore the pagination limit from session storage on page load
33
+ // Restore the pagination limit from URL or session storage on page load
49
34
  restorePaginationLimit() {
50
- const savedLimit = sessionStorage.getItem(this.storageKeyValue)
51
- if (savedLimit && this.limitTarget) {
52
- // Only set if the current value is different (prevents unnecessary DOM updates)
53
- if (this.limitTarget.value !== savedLimit) {
35
+ // URL params take precedence over session storage
36
+ const urlParams = new URLSearchParams(window.location.search)
37
+ const urlLimit = urlParams.get('limit')
38
+
39
+ if (urlLimit && this.limitTarget) {
40
+ // Sync sessionStorage with URL param
41
+ sessionStorage.setItem(this.storageKeyValue, urlLimit)
42
+ if (this.limitTarget.value !== urlLimit) {
43
+ this.limitTarget.value = urlLimit
44
+ }
45
+ } else {
46
+ // Fall back to sessionStorage if no URL param
47
+ const savedLimit = sessionStorage.getItem(this.storageKeyValue)
48
+ if (savedLimit && this.limitTarget && this.limitTarget.value !== savedLimit) {
54
49
  this.limitTarget.value = savedLimit
55
- // Don't trigger change event when restoring from session - prevents infinite loops
56
50
  }
57
51
  }
58
52
  }
@@ -1,7 +1,5 @@
1
1
  module RailsPulse
2
2
  class BackfillSummariesJob < ApplicationJob
3
- queue_as :low_priority
4
-
5
3
  def perform(start_date, end_date, period_types = [ "hour", "day" ])
6
4
  start_date = start_date.to_datetime
7
5
  end_date = end_date.to_datetime
@@ -1,7 +1,5 @@
1
1
  module RailsPulse
2
2
  class CleanupJob < ApplicationJob
3
- queue_as :default
4
-
5
3
  def perform
6
4
  return unless RailsPulse.configuration.archiving_enabled
7
5
 
@@ -1,7 +1,5 @@
1
1
  module RailsPulse
2
2
  class SummaryJob < ApplicationJob
3
- queue_as :low_priority
4
-
5
3
  def perform(target_hour = nil)
6
4
  target_hour ||= 1.hour.ago.beginning_of_hour
7
5