rails_pulse 0.1.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.
Files changed (160) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +638 -0
  4. data/Rakefile +207 -0
  5. data/app/assets/images/rails_pulse/dashboard.png +0 -0
  6. data/app/assets/images/rails_pulse/menu.svg +1 -0
  7. data/app/assets/images/rails_pulse/rails-pulse-logo.png +0 -0
  8. data/app/assets/images/rails_pulse/request.png +0 -0
  9. data/app/assets/images/rails_pulse/routes.png +0 -0
  10. data/app/assets/stylesheets/rails_pulse/application.css +102 -0
  11. data/app/assets/stylesheets/rails_pulse/components/alert.css +24 -0
  12. data/app/assets/stylesheets/rails_pulse/components/badge.css +58 -0
  13. data/app/assets/stylesheets/rails_pulse/components/base.css +79 -0
  14. data/app/assets/stylesheets/rails_pulse/components/breadcrumb.css +31 -0
  15. data/app/assets/stylesheets/rails_pulse/components/button.css +99 -0
  16. data/app/assets/stylesheets/rails_pulse/components/card.css +19 -0
  17. data/app/assets/stylesheets/rails_pulse/components/chart.css +18 -0
  18. data/app/assets/stylesheets/rails_pulse/components/csp_safe_positioning.css +86 -0
  19. data/app/assets/stylesheets/rails_pulse/components/descriptive_list.css +9 -0
  20. data/app/assets/stylesheets/rails_pulse/components/dialog.css +56 -0
  21. data/app/assets/stylesheets/rails_pulse/components/flash.css +47 -0
  22. data/app/assets/stylesheets/rails_pulse/components/input.css +80 -0
  23. data/app/assets/stylesheets/rails_pulse/components/layouts.css +63 -0
  24. data/app/assets/stylesheets/rails_pulse/components/menu.css +43 -0
  25. data/app/assets/stylesheets/rails_pulse/components/popover.css +36 -0
  26. data/app/assets/stylesheets/rails_pulse/components/prose.css +144 -0
  27. data/app/assets/stylesheets/rails_pulse/components/row.css +24 -0
  28. data/app/assets/stylesheets/rails_pulse/components/sidebar_menu.css +79 -0
  29. data/app/assets/stylesheets/rails_pulse/components/skeleton.css +5 -0
  30. data/app/assets/stylesheets/rails_pulse/components/table.css +37 -0
  31. data/app/assets/stylesheets/rails_pulse/components/utilities.css +36 -0
  32. data/app/controllers/concerns/chart_table_concern.rb +82 -0
  33. data/app/controllers/concerns/response_range_concern.rb +24 -0
  34. data/app/controllers/concerns/time_range_concern.rb +67 -0
  35. data/app/controllers/concerns/zoom_range_concern.rb +40 -0
  36. data/app/controllers/rails_pulse/application_controller.rb +67 -0
  37. data/app/controllers/rails_pulse/assets_controller.rb +33 -0
  38. data/app/controllers/rails_pulse/caches_controller.rb +115 -0
  39. data/app/controllers/rails_pulse/csp_test_controller.rb +57 -0
  40. data/app/controllers/rails_pulse/dashboard_controller.rb +6 -0
  41. data/app/controllers/rails_pulse/operations_controller.rb +219 -0
  42. data/app/controllers/rails_pulse/queries_controller.rb +121 -0
  43. data/app/controllers/rails_pulse/requests_controller.rb +69 -0
  44. data/app/controllers/rails_pulse/routes_controller.rb +99 -0
  45. data/app/helpers/rails_pulse/application_helper.rb +111 -0
  46. data/app/helpers/rails_pulse/breadcrumbs_helper.rb +62 -0
  47. data/app/helpers/rails_pulse/cached_component_helper.rb +73 -0
  48. data/app/helpers/rails_pulse/chart_formatters.rb +43 -0
  49. data/app/helpers/rails_pulse/chart_helper.rb +140 -0
  50. data/app/helpers/rails_pulse/formatting_helper.rb +29 -0
  51. data/app/helpers/rails_pulse/status_helper.rb +279 -0
  52. data/app/helpers/rails_pulse/table_helper.rb +54 -0
  53. data/app/javascript/rails_pulse/application.js +119 -0
  54. data/app/javascript/rails_pulse/controllers/color_scheme_controller.js +20 -0
  55. data/app/javascript/rails_pulse/controllers/context_menu_controller.js +16 -0
  56. data/app/javascript/rails_pulse/controllers/dialog_controller.js +21 -0
  57. data/app/javascript/rails_pulse/controllers/expandable_row_controller.js +67 -0
  58. data/app/javascript/rails_pulse/controllers/form_controller.js +39 -0
  59. data/app/javascript/rails_pulse/controllers/icon_controller.js +170 -0
  60. data/app/javascript/rails_pulse/controllers/index_controller.js +230 -0
  61. data/app/javascript/rails_pulse/controllers/menu_controller.js +60 -0
  62. data/app/javascript/rails_pulse/controllers/pagination_controller.js +69 -0
  63. data/app/javascript/rails_pulse/controllers/popover_controller.js +91 -0
  64. data/app/javascript/rails_pulse/controllers/timezone_controller.js +106 -0
  65. data/app/javascript/rails_pulse/theme.js +416 -0
  66. data/app/jobs/rails_pulse/application_job.rb +4 -0
  67. data/app/jobs/rails_pulse/cleanup_job.rb +21 -0
  68. data/app/mailers/rails_pulse/application_mailer.rb +6 -0
  69. data/app/models/rails_pulse/application_record.rb +7 -0
  70. data/app/models/rails_pulse/component_cache_key.rb +33 -0
  71. data/app/models/rails_pulse/dashboard/charts/average_response_time.rb +27 -0
  72. data/app/models/rails_pulse/dashboard/charts/p95_response_time.rb +37 -0
  73. data/app/models/rails_pulse/dashboard/tables/slow_queries.rb +59 -0
  74. data/app/models/rails_pulse/dashboard/tables/slow_routes.rb +45 -0
  75. data/app/models/rails_pulse/operation.rb +87 -0
  76. data/app/models/rails_pulse/queries/cards/average_query_times.rb +52 -0
  77. data/app/models/rails_pulse/queries/cards/execution_rate.rb +57 -0
  78. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +71 -0
  79. data/app/models/rails_pulse/queries/charts/average_query_times.rb +112 -0
  80. data/app/models/rails_pulse/query.rb +58 -0
  81. data/app/models/rails_pulse/request.rb +64 -0
  82. data/app/models/rails_pulse/requests/charts/average_response_times.rb +99 -0
  83. data/app/models/rails_pulse/requests/charts/operations_chart.rb +35 -0
  84. data/app/models/rails_pulse/route.rb +77 -0
  85. data/app/models/rails_pulse/routes/cards/average_response_times.rb +54 -0
  86. data/app/models/rails_pulse/routes/cards/error_rate_per_route.rb +73 -0
  87. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +73 -0
  88. data/app/models/rails_pulse/routes/cards/request_count_totals.rb +59 -0
  89. data/app/models/rails_pulse/routes/charts/average_response_times.rb +115 -0
  90. data/app/models/rails_pulse/routes/tables/index.rb +63 -0
  91. data/app/services/rails_pulse/sql_query_normalizer.rb +124 -0
  92. data/app/views/layouts/rails_pulse/_menu_items.html.erb +19 -0
  93. data/app/views/layouts/rails_pulse/_sidebar_menu.html.erb +44 -0
  94. data/app/views/layouts/rails_pulse/application.html.erb +72 -0
  95. data/app/views/rails_pulse/caches/show.html.erb +9 -0
  96. data/app/views/rails_pulse/components/_breadcrumbs.html.erb +12 -0
  97. data/app/views/rails_pulse/components/_code_panel.html.erb +12 -0
  98. data/app/views/rails_pulse/components/_metric_card.html.erb +55 -0
  99. data/app/views/rails_pulse/components/_metric_row.html.erb +9 -0
  100. data/app/views/rails_pulse/components/_operation_details_popover.html.erb +241 -0
  101. data/app/views/rails_pulse/components/_panel.html.erb +56 -0
  102. data/app/views/rails_pulse/components/_sparkline_stats.html.erb +15 -0
  103. data/app/views/rails_pulse/components/_table.html.erb +50 -0
  104. data/app/views/rails_pulse/components/_table_head.html.erb +20 -0
  105. data/app/views/rails_pulse/components/_table_pagination.html.erb +45 -0
  106. data/app/views/rails_pulse/components/_time_period.html.erb +16 -0
  107. data/app/views/rails_pulse/csp_test/show.html.erb +207 -0
  108. data/app/views/rails_pulse/dashboard/charts/_bar_chart.html.erb +1 -0
  109. data/app/views/rails_pulse/dashboard/index.html.erb +64 -0
  110. data/app/views/rails_pulse/dashboard/tables/_routes_table.html.erb +32 -0
  111. data/app/views/rails_pulse/dashboard/tables/_standard_table.html.erb +1 -0
  112. data/app/views/rails_pulse/operations/_operation_analysis_application.html.erb +43 -0
  113. data/app/views/rails_pulse/operations/_operation_analysis_database.html.erb +12 -0
  114. data/app/views/rails_pulse/operations/_operation_analysis_generic.html.erb +15 -0
  115. data/app/views/rails_pulse/operations/_operation_analysis_other.html.erb +69 -0
  116. data/app/views/rails_pulse/operations/_operation_analysis_view.html.erb +39 -0
  117. data/app/views/rails_pulse/operations/show.html.erb +79 -0
  118. data/app/views/rails_pulse/queries/_show_table.html.erb +19 -0
  119. data/app/views/rails_pulse/queries/_table.html.erb +31 -0
  120. data/app/views/rails_pulse/queries/index.html.erb +64 -0
  121. data/app/views/rails_pulse/queries/show.html.erb +86 -0
  122. data/app/views/rails_pulse/requests/_operations.html.erb +85 -0
  123. data/app/views/rails_pulse/requests/_table.html.erb +31 -0
  124. data/app/views/rails_pulse/requests/index.html.erb +64 -0
  125. data/app/views/rails_pulse/requests/show.html.erb +44 -0
  126. data/app/views/rails_pulse/routes/_table.html.erb +29 -0
  127. data/app/views/rails_pulse/routes/index.html.erb +65 -0
  128. data/app/views/rails_pulse/routes/show.html.erb +67 -0
  129. data/app/views/rails_pulse/skeletons/_chart.html.erb +3 -0
  130. data/app/views/rails_pulse/skeletons/_metric_card.html.erb +20 -0
  131. data/app/views/rails_pulse/skeletons/_panel.html.erb +19 -0
  132. data/app/views/rails_pulse/skeletons/_table.html.erb +8 -0
  133. data/config/importmap.rb +12 -0
  134. data/config/initializers/rails_charts_csp_patch.rb +83 -0
  135. data/config/initializers/rails_pulse.rb +198 -0
  136. data/config/routes.rb +16 -0
  137. data/db/migrate/20250227235904_create_routes.rb +12 -0
  138. data/db/migrate/20250227235915_create_requests.rb +19 -0
  139. data/db/migrate/20250228000000_create_queries.rb +14 -0
  140. data/db/migrate/20250228000056_create_operations.rb +24 -0
  141. data/lib/generators/rails_pulse/install_generator.rb +17 -0
  142. data/lib/generators/rails_pulse/templates/rails_pulse.rb +198 -0
  143. data/lib/rails_pulse/cleanup_service.rb +212 -0
  144. data/lib/rails_pulse/configuration.rb +176 -0
  145. data/lib/rails_pulse/engine.rb +88 -0
  146. data/lib/rails_pulse/middleware/asset_server.rb +84 -0
  147. data/lib/rails_pulse/middleware/request_collector.rb +120 -0
  148. data/lib/rails_pulse/migration.rb +29 -0
  149. data/lib/rails_pulse/subscribers/operation_subscriber.rb +280 -0
  150. data/lib/rails_pulse/version.rb +3 -0
  151. data/lib/rails_pulse.rb +38 -0
  152. data/lib/tasks/rails_pulse_tasks.rake +138 -0
  153. data/public/rails-pulse-assets/csp-test.js +110 -0
  154. data/public/rails-pulse-assets/rails-pulse-icons.js +89 -0
  155. data/public/rails-pulse-assets/rails-pulse-icons.js.map +13 -0
  156. data/public/rails-pulse-assets/rails-pulse.css +1 -0
  157. data/public/rails-pulse-assets/rails-pulse.css.map +1 -0
  158. data/public/rails-pulse-assets/rails-pulse.js +183 -0
  159. data/public/rails-pulse-assets/rails-pulse.js.map +7 -0
  160. metadata +339 -0
@@ -0,0 +1,119 @@
1
+ import * as echarts from "echarts";
2
+ import "./theme";
3
+ import * as Turbo from "@hotwired/turbo";
4
+ import { Application } from "@hotwired/stimulus";
5
+
6
+ // CSS Zero Controllers
7
+ import ContextMenuController from "./controllers/context_menu_controller";
8
+ import DialogController from "./controllers/dialog_controller";
9
+ import MenuController from "./controllers/menu_controller";
10
+ import PopoverController from "./controllers/popover_controller";
11
+ import FormController from "./controllers/form_controller";
12
+
13
+ // Rails Pulse Controllers
14
+ import IndexController from "./controllers/index_controller";
15
+ import ColorSchemeController from "./controllers/color_scheme_controller";
16
+ import PaginationController from "./controllers/pagination_controller";
17
+ import TimezoneController from "./controllers/timezone_controller";
18
+ import IconController from "./controllers/icon_controller";
19
+ import ExpandableRowController from "./controllers/expandable_row_controller";
20
+
21
+ const application = Application.start();
22
+
23
+ // Configure Stimulus application
24
+ application.debug = false;
25
+ window.Stimulus = application;
26
+
27
+ // Make ECharts available globally for rails_charts gem
28
+ window.echarts = echarts;
29
+
30
+ // Make Turbo available globally
31
+ window.Turbo = Turbo;
32
+
33
+ application.register("rails-pulse--context-menu", ContextMenuController);
34
+ application.register("rails-pulse--dialog", DialogController);
35
+ application.register("rails-pulse--menu", MenuController);
36
+ application.register("rails-pulse--popover", PopoverController);
37
+ application.register("rails-pulse--form", FormController);
38
+
39
+ application.register("rails-pulse--index", IndexController);
40
+ application.register("rails-pulse--color-scheme", ColorSchemeController);
41
+ application.register("rails-pulse--pagination", PaginationController);
42
+ application.register("rails-pulse--timezone", TimezoneController);
43
+ application.register("rails-pulse--icon", IconController);
44
+ application.register("rails-pulse--expandable-row", ExpandableRowController);
45
+
46
+ // Ensure Turbo Frames are loaded after page load
47
+ document.addEventListener('DOMContentLoaded', () => {
48
+ // Force Turbo to process any frames with src attributes
49
+ const frames = document.querySelectorAll('turbo-frame[src]:not([complete])');
50
+ frames.forEach(frame => {
51
+ // Trigger frame loading by temporarily removing and re-adding src
52
+ const src = frame.getAttribute('src');
53
+ if (src) {
54
+ frame.removeAttribute('src');
55
+ setTimeout(() => frame.setAttribute('src', src), 10);
56
+ }
57
+ });
58
+ });
59
+
60
+ // Also handle frames that are added dynamically
61
+ const observer = new MutationObserver((mutations) => {
62
+ mutations.forEach((mutation) => {
63
+ if (mutation.type === 'childList') {
64
+ mutation.addedNodes.forEach((node) => {
65
+ if (node.nodeType === 1 && node.tagName === 'TURBO-FRAME' && node.hasAttribute('src') && !node.hasAttribute('complete')) {
66
+ const src = node.getAttribute('src');
67
+ if (src) {
68
+ node.removeAttribute('src');
69
+ setTimeout(() => node.setAttribute('src', src), 10);
70
+ }
71
+ }
72
+ });
73
+ }
74
+ });
75
+ });
76
+
77
+ observer.observe(document.body, { childList: true, subtree: true });
78
+
79
+ // Register ECharts theme for Rails Pulse
80
+ echarts.registerTheme('railspulse', {
81
+ "color": ["#ffc91f", "#ffde66", "#fbedbf"],
82
+ "backgroundColor": "rgba(255,255,255,0)",
83
+ "textStyle": {},
84
+ "title": { "textStyle": { "color": "#666666" } },
85
+ "line": { "lineStyle": { "width": "3" }, "symbolSize": "8" },
86
+ "bar": { "itemStyle": { "barBorderWidth": 0 } }
87
+ });
88
+
89
+ // Chart resize functionality (moved from inline script for CSP compliance)
90
+ window.addEventListener('resize', function() {
91
+ if (window.RailsCharts && window.RailsCharts.charts) {
92
+ Object.keys(window.RailsCharts.charts).forEach(function(chartID) {
93
+ window.RailsCharts.charts[chartID].resize();
94
+ });
95
+ }
96
+ });
97
+
98
+ // Global function to initialize Rails Charts in any container.
99
+ // This is needed as we render Rails Charts in Turbo Frames.
100
+ window.initializeChartsInContainer = function(containerId) {
101
+ requestAnimationFrame(() => {
102
+ const container = containerId ? document.getElementById(containerId) : document;
103
+ const scripts = container.querySelectorAll('script');
104
+ scripts.forEach(script => {
105
+ const content = script.textContent;
106
+ const match = content.match(/function\s+(init_rails_charts_[a-f0-9]+)/);
107
+ if (match && window[match[1]]) {
108
+ window[match[1]]();
109
+ }
110
+ });
111
+ });
112
+ };
113
+
114
+ // Export for global access
115
+ window.RailsPulse = {
116
+ application,
117
+ version: "1.0.0"
118
+ };
119
+
@@ -0,0 +1,20 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ connect() {
5
+ this.storageKey = "color-scheme"
6
+ this.html = document.documentElement
7
+ const saved = localStorage.getItem(this.storageKey)
8
+ if (saved) {
9
+ this.html.setAttribute("data-color-scheme", saved)
10
+ }
11
+ }
12
+
13
+ toggle(event) {
14
+ event.preventDefault()
15
+ const current = this.html.getAttribute("data-color-scheme") === "dark" ? "light" : "dark"
16
+ console.log("Toggling color scheme to", current)
17
+ this.html.setAttribute("data-color-scheme", current)
18
+ localStorage.setItem(this.storageKey, current)
19
+ }
20
+ }
@@ -0,0 +1,16 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = [ "menu" ]
5
+
6
+ show(event) {
7
+ // Use CSS custom properties instead of inline styles for CSP safety
8
+ this.menuTarget.style.setProperty('--context-menu-x', `${event.clientX - 5}px`)
9
+ this.menuTarget.style.setProperty('--context-menu-y', `${event.clientY - 5}px`)
10
+
11
+ // Add CSS class to apply positioning via CSS custom properties
12
+ this.menuTarget.classList.add('positioned')
13
+
14
+ setTimeout(() => this.menuTarget.showPopover(), 150)
15
+ }
16
+ }
@@ -0,0 +1,21 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = [ "menu" ]
5
+
6
+ show() {
7
+ this.menuTarget.show()
8
+ }
9
+
10
+ showModal() {
11
+ this.menuTarget.showModal()
12
+ }
13
+
14
+ close() {
15
+ this.menuTarget.close()
16
+ }
17
+
18
+ closeOnClickOutside({ target }) {
19
+ target.nodeName === "DIALOG" && this.close()
20
+ }
21
+ }
@@ -0,0 +1,67 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["trigger", "details", "chevron"]
5
+
6
+ connect() {
7
+ // Ensure details row is initially hidden
8
+ this.detailsTarget.classList.add("hidden")
9
+ this.loaded = false
10
+ }
11
+
12
+ toggle(event) {
13
+ event.preventDefault()
14
+ event.stopPropagation()
15
+
16
+ const isExpanded = !this.detailsTarget.classList.contains("hidden")
17
+ console.log('Toggle clicked, currently expanded:', isExpanded)
18
+
19
+ console.log('isExpanded', isExpanded)
20
+ if (isExpanded) {
21
+ console.log('Collapsing...')
22
+ this.collapse()
23
+ } else {
24
+ console.log('Expanding...')
25
+ this.expand()
26
+ }
27
+ }
28
+
29
+ expand() {
30
+ // Show details row
31
+ this.detailsTarget.classList.remove("hidden")
32
+
33
+ // Rotate chevron to point down
34
+ this.chevronTarget.style.transform = "rotate(90deg)"
35
+
36
+ // Add expanded state class to trigger row
37
+ this.triggerTarget.classList.add("expanded")
38
+
39
+ // Load content lazily on first expansion
40
+ if (!this.loaded) {
41
+ this.loadOperationDetails()
42
+ this.loaded = true
43
+ }
44
+ }
45
+
46
+ loadOperationDetails() {
47
+ // Find the turbo frame and set its src to trigger loading
48
+ const turboFrame = this.detailsTarget.querySelector('turbo-frame')
49
+ if (turboFrame) {
50
+ const operationUrl = turboFrame.dataset.operationUrl
51
+ if (operationUrl) {
52
+ turboFrame.src = operationUrl
53
+ }
54
+ }
55
+ }
56
+
57
+ collapse() {
58
+ // Hide details row
59
+ this.detailsTarget.classList.add("hidden")
60
+
61
+ // Rotate chevron back to point right
62
+ this.chevronTarget.style.transform = "rotate(0deg)"
63
+
64
+ // Remove expanded state class from trigger row
65
+ this.triggerTarget.classList.remove("expanded")
66
+ }
67
+ }
@@ -0,0 +1,39 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = [ "cancel" ]
5
+
6
+ initialize() {
7
+ // Simple debounce implementation for asset independence
8
+ this.search = this.debounce(this.search.bind(this), 500)
9
+ }
10
+
11
+ // Simple debounce implementation (replaces lodash dependency)
12
+ debounce(func, wait) {
13
+ let timeout
14
+ return function executedFunction(...args) {
15
+ const later = () => {
16
+ clearTimeout(timeout)
17
+ func(...args)
18
+ }
19
+ clearTimeout(timeout)
20
+ timeout = setTimeout(later, wait)
21
+ }
22
+ }
23
+
24
+ submit() {
25
+ this.element.requestSubmit()
26
+ }
27
+
28
+ search() {
29
+ this.element.requestSubmit()
30
+ }
31
+
32
+ cancel() {
33
+ this.cancelTarget?.click()
34
+ }
35
+
36
+ preventAttachment(event) {
37
+ event.preventDefault()
38
+ }
39
+ }
@@ -0,0 +1,170 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // CSP-Safe Icon Controller for Rails Pulse
4
+ // Integrates with window.RailsPulseIcons for pre-compiled icon access
5
+ export default class extends Controller {
6
+ static values = {
7
+ name: String, // Icon name (e.g., "menu", "chevron-right")
8
+ width: String, // Icon width (default: "24")
9
+ height: String, // Icon height (default: "24")
10
+ strokeWidth: String // Stroke width (default: "2")
11
+ }
12
+
13
+ static classes = ["loading", "error", "loaded"]
14
+
15
+ connect() {
16
+ this.renderIcon()
17
+ }
18
+
19
+ // Called when icon name changes
20
+ nameValueChanged() {
21
+ this.renderIcon()
22
+ }
23
+
24
+ // CSP-safe icon rendering using DOM methods
25
+ renderIcon() {
26
+ // Clear any existing content
27
+ this.clearIcon()
28
+
29
+ // Add loading state
30
+ this.element.classList.add(...this.loadingClasses)
31
+ this.element.classList.remove(...this.errorClasses, ...this.loadedClasses)
32
+
33
+ // Get icon name
34
+ const iconName = this.nameValue
35
+ if (!iconName) {
36
+ this.handleError(`Icon name is required`)
37
+ return
38
+ }
39
+
40
+ // Check if RailsPulseIcons is available
41
+ if (!window.RailsPulseIcons) {
42
+ this.handleError(`RailsPulseIcons not loaded`)
43
+ return
44
+ }
45
+
46
+ // Get icon SVG content
47
+ const svgContent = window.RailsPulseIcons.get(iconName)
48
+ if (!svgContent) {
49
+ this.handleError(`Icon '${iconName}' not found`)
50
+ return
51
+ }
52
+
53
+ // Create SVG element using CSP-safe DOM methods
54
+ try {
55
+ const svg = this.createSVGElement(svgContent)
56
+ this.element.appendChild(svg)
57
+
58
+ // Update state to loaded
59
+ this.element.classList.remove(...this.loadingClasses, ...this.errorClasses)
60
+ this.element.classList.add(...this.loadedClasses)
61
+
62
+ // Set aria-label for accessibility
63
+ this.element.setAttribute('aria-label', `${iconName} icon`)
64
+
65
+ } catch (error) {
66
+ this.handleError(`Failed to render icon '${iconName}': ${error.message}`)
67
+ }
68
+ }
69
+
70
+ // Create SVG element using CSP-safe DOM methods
71
+ createSVGElement(svgContent) {
72
+ // Create SVG element with proper namespace
73
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
74
+
75
+ // Set SVG attributes
76
+ svg.setAttribute('width', this.widthValue || '24')
77
+ svg.setAttribute('height', this.heightValue || '24')
78
+ svg.setAttribute('viewBox', '0 0 24 24')
79
+ svg.setAttribute('fill', 'none')
80
+ svg.setAttribute('stroke', 'currentColor')
81
+ svg.setAttribute('stroke-width', this.strokeWidthValue || '2')
82
+ svg.setAttribute('stroke-linecap', 'round')
83
+ svg.setAttribute('stroke-linejoin', 'round')
84
+
85
+ // Use the RailsPulseIcons render method for CSP-safe injection
86
+ if (window.RailsPulseIcons.render) {
87
+ // Clear the element and let RailsPulseIcons handle the rendering
88
+ const tempDiv = document.createElement('div')
89
+ const success = window.RailsPulseIcons.render(this.nameValue, tempDiv, {
90
+ width: this.widthValue || '24',
91
+ height: this.heightValue || '24'
92
+ })
93
+
94
+ if (success && tempDiv.firstChild) {
95
+ return tempDiv.firstChild
96
+ }
97
+ }
98
+
99
+ // Fallback: manually parse SVG content using DOMParser
100
+ const parser = new DOMParser()
101
+ const svgDoc = parser.parseFromString(
102
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${this.widthValue || '24'}" height="${this.heightValue || '24'}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="${this.strokeWidthValue || '2'}" stroke-linecap="round" stroke-linejoin="round">${svgContent}</svg>`,
103
+ 'image/svg+xml'
104
+ )
105
+
106
+ const parsedSvg = svgDoc.documentElement
107
+ if (parsedSvg.nodeName === 'parsererror') {
108
+ throw new Error('Invalid SVG content')
109
+ }
110
+
111
+ // Import the node into the current document
112
+ return document.importNode(parsedSvg, true)
113
+ }
114
+
115
+ // Clear icon content safely
116
+ clearIcon() {
117
+ // Use DOM methods to clear content (CSP-safe)
118
+ while (this.element.firstChild) {
119
+ this.element.removeChild(this.element.firstChild)
120
+ }
121
+ }
122
+
123
+ // Handle icon loading errors
124
+ handleError(message) {
125
+ console.warn(`[Rails Pulse Icon Controller] ${message}`)
126
+
127
+ // Clear any existing content
128
+ this.clearIcon()
129
+
130
+ // Update state to error
131
+ this.element.classList.remove(...this.loadingClasses, ...this.loadedClasses)
132
+ this.element.classList.add(...this.errorClasses)
133
+
134
+ // Create fallback placeholder using CSP-safe methods
135
+ this.createErrorPlaceholder()
136
+
137
+ // Set aria-label for accessibility
138
+ this.element.setAttribute('aria-label', 'Icon not available')
139
+ }
140
+
141
+ // Create error placeholder using CSP-safe DOM methods
142
+ createErrorPlaceholder() {
143
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
144
+ svg.setAttribute('width', this.widthValue || '24')
145
+ svg.setAttribute('height', this.heightValue || '24')
146
+ svg.setAttribute('viewBox', '0 0 24 24')
147
+ svg.setAttribute('fill', 'currentColor')
148
+ svg.setAttribute('opacity', '0.3')
149
+
150
+ // Create a simple rectangle as placeholder
151
+ const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
152
+ rect.setAttribute('width', '20')
153
+ rect.setAttribute('height', '20')
154
+ rect.setAttribute('x', '2')
155
+ rect.setAttribute('y', '2')
156
+ rect.setAttribute('rx', '2')
157
+
158
+ svg.appendChild(rect)
159
+ this.element.appendChild(svg)
160
+ }
161
+
162
+ // Debug method to list available icons
163
+ listAvailableIcons() {
164
+ if (window.RailsPulseIcons?.list) {
165
+ console.log('Available icons:', window.RailsPulseIcons.list())
166
+ } else {
167
+ console.warn('RailsPulseIcons not loaded or list method not available')
168
+ }
169
+ }
170
+ }
@@ -0,0 +1,230 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["chart", "paginationLimit", "indexTable"] // The chart element to be monitored
5
+
6
+ static values = {
7
+ chartId: String // The ID of the chart to be monitored
8
+ }
9
+
10
+ // Add a property to track the last request time
11
+ lastTurboFrameRequestAt = 0;
12
+
13
+ connect() {
14
+ // Listen for the custom event 'chart:initialized' to set up the chart.
15
+ // This event is sent from the RailsCharts library when the chart is ready.
16
+ this.handleChartInitialized = this.onChartInitialized.bind(this);
17
+ document.addEventListener('chart:initialized', this.handleChartInitialized);
18
+
19
+ // If the chart is already initialized (e.g., on back navigation), set up immediately
20
+ if (window.RailsCharts?.charts?.[this.chartIdValue]) { this.setup(); }
21
+ }
22
+
23
+ disconnect() {
24
+ // Remove the event listener from RailsCharts when the controller is disconnected
25
+ document.removeEventListener('chart:initialized', this.handleChartInitialized);
26
+
27
+ // Remove chart event listeners if they exist
28
+ if (this.chartTarget) {
29
+ this.chartTarget.removeEventListener('mousedown', this.handleChartMouseDown);
30
+ this.chartTarget.removeEventListener('mouseup', this.handleChartMouseUp);
31
+ }
32
+ document.removeEventListener('mouseup', this.handleDocumentMouseUp);
33
+ }
34
+
35
+ // After the chart is initialized, set up the event listeners and data tracking
36
+ onChartInitialized(event) {
37
+ if (event.detail.chartId === this.chartIdValue) { this.setup(); }
38
+ }
39
+
40
+ setup() {
41
+ if (this.setupDone) return; // Prevent multiple setups
42
+
43
+ // Get the chart element which the RailsCharts library has created
44
+ this.chart = window.RailsCharts.charts[this.chartIdValue];
45
+ if (!this.chart) return;
46
+
47
+ this.visibleData = this.getVisibleData();
48
+ this.setupChartEventListeners();
49
+ this.setupDone = true;
50
+ }
51
+
52
+ // Add some event listeners to the chart so we can track the zoom changes
53
+ setupChartEventListeners() {
54
+ // When clicking on the chart, we want to store the current visible data so we can compare it later
55
+ this.handleChartMouseDown = () => { this.visibleData = this.getVisibleData(); };
56
+ this.chartTarget.addEventListener('mousedown', this.handleChartMouseDown);
57
+
58
+ // When releasing the mouse button, we want to check if the visible data has changed
59
+ this.handleChartMouseUp = () => { this.handleZoomChange(); };
60
+ this.chartTarget.addEventListener('mouseup', this.handleChartMouseUp);
61
+
62
+ // When the chart is zoomed, we want to check if the visible data has changed
63
+ this.chart.on('datazoom', () => { this.handleZoomChange(); });
64
+
65
+ // When releasing the mouse button outside the chart, we want to check if the visible data has changed
66
+ this.handleDocumentMouseUp = () => { this.handleZoomChange(); };
67
+ document.addEventListener('mouseup', this.handleDocumentMouseUp);
68
+ }
69
+
70
+ // This returns the visible data from the chart based on the current zoom level.
71
+ // The xAxis data and series data are sliced based on the start and end values of the dataZoom component.
72
+ // The series data will contain the actual data points that are visible in the chart.
73
+ getVisibleData() {
74
+ const currentOption = this.chart.getOption();
75
+ const dataZoom = currentOption.dataZoom[1];
76
+ const xAxisData = currentOption.xAxis[0].data;
77
+ const seriesData = currentOption.series[0].data;
78
+
79
+ const startValue = dataZoom.startValue;
80
+ const endValue = dataZoom.endValue;
81
+
82
+ return {
83
+ xAxis: xAxisData.slice(startValue, endValue + 1),
84
+ series: seriesData.slice(startValue, endValue + 1)
85
+ };
86
+ }
87
+
88
+ // When the zoom level changes, we want to check if the visible data has changed
89
+ // If it has, we want to send a request to the server with the new visible data so
90
+ // we can update the table with the new data that is visible in the chart.
91
+ handleZoomChange() {
92
+ const newVisibleData = this.getVisibleData();
93
+ if (newVisibleData.xAxis.join() !== this.visibleData.xAxis.join()) {
94
+ this.visibleData = newVisibleData;
95
+ this.updateUrlWithZoomParams(newVisibleData);
96
+ this.sendTurboFrameRequest(newVisibleData);
97
+ }
98
+ }
99
+
100
+ // Update the browser URL with zoom parameters so they persist on page refresh
101
+ updateUrlWithZoomParams(data) {
102
+ const url = new URL(window.location.href);
103
+ const currentParams = new URLSearchParams(url.search);
104
+
105
+ const startTimestamp = data.xAxis[0];
106
+ const endTimestamp = data.xAxis[data.xAxis.length - 1];
107
+
108
+ // Update zoom parameters in URL
109
+ currentParams.set('zoom_start_time', startTimestamp);
110
+ currentParams.set('zoom_end_time', endTimestamp);
111
+
112
+ url.search = currentParams.toString();
113
+ window.history.replaceState({}, '', url);
114
+ }
115
+
116
+ updatePaginationLimit() {
117
+ // Update or set the limit param in the browser so if the user refreshes the page,
118
+ // the limit will be preserved.
119
+ const url = new URL(window.location.href);
120
+ const currentParams = new URLSearchParams(url.search);
121
+ const limit = this.paginationLimitTarget.value;
122
+ currentParams.set('limit', limit);
123
+ url.search = currentParams.toString();
124
+ window.history.replaceState({}, '', url);
125
+ }
126
+
127
+ // After the zoom level changes, we want to send a request to the server with the new visible data.
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.
130
+ sendTurboFrameRequest(data) {
131
+ const now = Date.now();
132
+ // If less than 1 second since last request, ignore this call
133
+ if (now - this.lastTurboFrameRequestAt < 1000) { return; }
134
+ this.lastTurboFrameRequestAt = now;
135
+
136
+ // Start with the current page's URL
137
+ const url = new URL(window.location.href);
138
+
139
+ // Preserve existing URL parameters
140
+ const currentParams = new URLSearchParams(url.search);
141
+
142
+ const startTimestamp = data.xAxis[0];
143
+ const endTimestamp = data.xAxis[data.xAxis.length - 1];
144
+
145
+ // Add or update the zoom occurred_at parameters for table filtering
146
+ currentParams.set('zoom_start_time', startTimestamp);
147
+ currentParams.set('zoom_end_time', endTimestamp);
148
+
149
+ // Set the limit param based on the value in the pagination selector
150
+ url.searchParams.set('limit', this.paginationLimitTarget.value);
151
+
152
+ // Update the URL's search parameters
153
+ url.search = currentParams.toString();
154
+
155
+ fetch(url, {
156
+ method: 'GET',
157
+ headers: {
158
+ 'Accept': 'text/html; turbo-frame',
159
+ 'Turbo-Frame': this.chartIdValue
160
+ }
161
+ })
162
+ .then(response => response.text()) // Get the raw HTML response
163
+ .then(html => {
164
+ // Find the turbo-frame in the document using the target
165
+ const frame = this.indexTableTarget;
166
+ if (frame) {
167
+ // Parse the response HTML
168
+ const parser = new DOMParser();
169
+ const doc = parser.parseFromString(html, 'text/html');
170
+
171
+ // Find the turbo-frame in the response using the frame's ID
172
+ const responseFrame = doc.querySelector(`turbo-frame#${frame.id}`);
173
+ if (responseFrame) {
174
+ // CSP-safe content replacement using DOM methods
175
+ this.replaceFrameContent(frame, responseFrame);
176
+ } else {
177
+ // Fallback: parse the entire HTML response
178
+ this.replaceFrameContentFromHTML(frame, html);
179
+ }
180
+ }
181
+ })
182
+ .catch(error => console.error('Error:', error));
183
+ }
184
+
185
+ // CSP-safe method to replace frame content using DOM methods
186
+ replaceFrameContent(targetFrame, sourceFrame) {
187
+ try {
188
+ // Clear existing content using DOM methods
189
+ while (targetFrame.firstChild) {
190
+ targetFrame.removeChild(targetFrame.firstChild);
191
+ }
192
+
193
+ // Clone and append all child nodes from source frame
194
+ const children = Array.from(sourceFrame.childNodes);
195
+ children.forEach(child => {
196
+ const clonedChild = child.cloneNode(true);
197
+ targetFrame.appendChild(clonedChild);
198
+ });
199
+ } catch (error) {
200
+ console.error('Error replacing frame content:', error);
201
+ // Fallback to innerHTML as last resort (not ideal for CSP)
202
+ targetFrame.innerHTML = sourceFrame.innerHTML;
203
+ }
204
+ }
205
+
206
+ // CSP-safe fallback method for parsing raw HTML
207
+ replaceFrameContentFromHTML(targetFrame, html) {
208
+ try {
209
+ // Parse HTML safely
210
+ const parser = new DOMParser();
211
+ const doc = parser.parseFromString(html, 'text/html');
212
+
213
+ // Clear existing content
214
+ while (targetFrame.firstChild) {
215
+ targetFrame.removeChild(targetFrame.firstChild);
216
+ }
217
+
218
+ // If the HTML contains a single root element, use its children
219
+ const bodyChildren = Array.from(doc.body.childNodes);
220
+ bodyChildren.forEach(child => {
221
+ const clonedChild = child.cloneNode(true);
222
+ targetFrame.appendChild(clonedChild);
223
+ });
224
+ } catch (error) {
225
+ console.error('Error parsing HTML content:', error);
226
+ // Last resort fallback
227
+ targetFrame.innerHTML = html;
228
+ }
229
+ }
230
+ }