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,60 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = [ "item" ]
5
+ static values = { index: Number }
6
+
7
+ #observer
8
+
9
+ initialize() {
10
+ this.#observer = new IntersectionObserver(this.#reset.bind(this))
11
+ }
12
+
13
+ connect() {
14
+ this.#observer.observe(this.element)
15
+ }
16
+
17
+ disconnect() {
18
+ this.#observer.disconnect()
19
+ }
20
+
21
+ prev() {
22
+ if (this.indexValue > 0) {
23
+ this.indexValue--
24
+ this.#update()
25
+ }
26
+ }
27
+
28
+ next() {
29
+ if (this.indexValue < this.#lastIndex) {
30
+ this.indexValue++
31
+ this.#update()
32
+ }
33
+ }
34
+
35
+ #reset([ entry ]) {
36
+ if (entry.isIntersecting) {
37
+ this.indexValue = 0
38
+ this.#update()
39
+ }
40
+ }
41
+
42
+ #update() {
43
+ this.#updateTabstops()
44
+ this.#focusCurrentItem()
45
+ }
46
+
47
+ #updateTabstops() {
48
+ this.itemTargets.forEach((it, index) => {
49
+ it.tabIndex = index === this.indexValue ? 0 : -1
50
+ })
51
+ }
52
+
53
+ #focusCurrentItem() {
54
+ this.itemTargets[this.indexValue].focus()
55
+ }
56
+
57
+ get #lastIndex() {
58
+ return this.itemTargets.length -1
59
+ }
60
+ }
@@ -0,0 +1,69 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["limit"]
5
+ static values = {
6
+ storageKey: { type: String, default: "rails_pulse_pagination_limit" },
7
+ url: String
8
+ }
9
+
10
+ connect() {
11
+ this.restorePaginationLimit()
12
+ }
13
+
14
+ // Update pagination limit via AJAX and reload the page to reflect changes
15
+ async updateLimit() {
16
+ const limit = this.limitTarget.value
17
+
18
+ // Save to session storage
19
+ sessionStorage.setItem(this.storageKeyValue, limit)
20
+
21
+ try {
22
+ // Send AJAX request to update server session using Rails.ajax
23
+ const response = await fetch(this.urlValue, {
24
+ method: 'PATCH',
25
+ headers: {
26
+ 'Content-Type': 'application/json',
27
+ 'X-CSRF-Token': this.getCSRFToken()
28
+ },
29
+ body: JSON.stringify({ limit: limit })
30
+ })
31
+
32
+ if (response.ok) {
33
+ // Reload the page to reflect the new pagination limit
34
+ // This preserves all current URL parameters including Ransack search params
35
+ window.location.reload()
36
+ } else {
37
+ throw new Error(`HTTP error! status: ${response.status}`)
38
+ }
39
+ } catch (error) {
40
+ console.error('Error updating pagination limit:', error)
41
+ }
42
+ }
43
+
44
+ // Get CSRF token from meta tag
45
+ getCSRFToken() {
46
+ const token = document.querySelector('meta[name="csrf-token"]')
47
+ return token ? token.getAttribute('content') : ''
48
+ }
49
+
50
+ // Save the pagination limit to session storage when it changes
51
+ savePaginationLimit() {
52
+ const limit = this.limitTarget.value
53
+ sessionStorage.setItem(this.storageKeyValue, limit)
54
+ }
55
+
56
+ // Restore the pagination limit from session storage on page load
57
+ restorePaginationLimit() {
58
+ const savedLimit = sessionStorage.getItem(this.storageKeyValue)
59
+ if (savedLimit && this.limitTarget) {
60
+ // Only set if the current value is different (prevents unnecessary DOM updates)
61
+ if (this.limitTarget.value !== savedLimit) {
62
+ this.limitTarget.value = savedLimit
63
+
64
+ // Trigger a change event to ensure any other listeners are notified
65
+ this.limitTarget.dispatchEvent(new Event('change', { bubbles: true }))
66
+ }
67
+ }
68
+ }
69
+ }
@@ -0,0 +1,91 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { computePosition, flip, shift, offset, autoUpdate } from "@floating-ui/dom"
3
+
4
+ export default class extends Controller {
5
+ static targets = [ "button", "menu" ]
6
+ static values = { placement: { type: String, default: "bottom" } }
7
+
8
+ #showTimer = null
9
+ #hideTimer = null
10
+
11
+ initialize() {
12
+ this.orient = this.orient.bind(this)
13
+ }
14
+
15
+ connect() {
16
+ this.cleanup = autoUpdate(this.buttonTarget, this.menuTarget, this.orient)
17
+ }
18
+
19
+ disconnect() {
20
+ this.cleanup()
21
+ }
22
+
23
+ show(event) {
24
+ if (event) event.preventDefault()
25
+ this.menuTarget.showPopover({ source: this.buttonTarget })
26
+ // Explicitly call orient after showing to ensure positioning
27
+ this.orient()
28
+ this.loadOperationDetailsIfNeeded()
29
+ }
30
+
31
+ hide() {
32
+ this.menuTarget.hidePopover()
33
+ }
34
+
35
+ toggle(event) {
36
+ event.preventDefault()
37
+ this.menuTarget.togglePopover({ source: this.buttonTarget })
38
+ // Explicitly call orient after toggling to ensure positioning
39
+ this.orient()
40
+ this.loadOperationDetailsIfNeeded()
41
+ }
42
+
43
+ debouncedShow() {
44
+ clearTimeout(this.#hideTimer)
45
+ this.#showTimer = setTimeout(() => this.show(), 700)
46
+ }
47
+
48
+ debouncedHide() {
49
+ clearTimeout(this.#showTimer)
50
+ this.#hideTimer = setTimeout(() => this.hide(), 300)
51
+ }
52
+
53
+ orient() {
54
+ computePosition(this.buttonTarget, this.menuTarget, this.#options).then(({x, y}) => {
55
+ // Use CSS custom properties for CSP compliance
56
+ this.menuTarget.style.setProperty('--popover-x', `${x}px`)
57
+ this.menuTarget.style.setProperty('--popover-y', `${y}px`)
58
+ // Add class to apply the positioning
59
+ this.menuTarget.classList.add('positioned')
60
+ })
61
+ }
62
+
63
+ loadOperationDetailsIfNeeded() {
64
+ // Check if this popover has operation details to load
65
+ const operationUrl = this.menuTarget.dataset.operationUrl
66
+ if (!operationUrl) return
67
+
68
+ // Find the turbo frame inside the popover
69
+ const turboFrame = this.menuTarget.querySelector('turbo-frame')
70
+ if (!turboFrame) return
71
+
72
+ // Only load if not already loaded (check if still shows loading content)
73
+ // Use CSP-safe method to check for loading content
74
+ const hasLoadingContent = this.hasLoadingContent(turboFrame)
75
+ if (!hasLoadingContent) return
76
+
77
+ // Set the src attribute to trigger the turbo frame loading
78
+ turboFrame.src = operationUrl
79
+ }
80
+
81
+ // CSP-safe method to check for loading content
82
+ hasLoadingContent(element) {
83
+ // Use textContent instead of innerHTML to avoid CSP issues
84
+ const textContent = element.textContent || ''
85
+ return textContent.includes('Loading operation details')
86
+ }
87
+
88
+ get #options() {
89
+ return { placement: this.placementValue, middleware: [offset(4), flip(), shift({padding: 4})] }
90
+ }
91
+ }
@@ -0,0 +1,106 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static values = { cachedAt: String, targetFrame: String }
5
+
6
+ connect() {
7
+ this.updateTimestamp()
8
+ this.setupObserver()
9
+ this.setupTurboFrameListener()
10
+ }
11
+
12
+ disconnect() {
13
+ if (this.observer) {
14
+ this.observer.disconnect()
15
+ }
16
+ if (this.frameObserver) {
17
+ this.frameObserver.disconnect()
18
+ }
19
+ if (this.documentObserver) {
20
+ this.documentObserver.disconnect()
21
+ }
22
+ if (this.turboFrameListener) {
23
+ document.removeEventListener('turbo:frame-load', this.turboFrameListener)
24
+ }
25
+ }
26
+
27
+ setupObserver() {
28
+ if (this.targetFrameValue) {
29
+ // Try immediately
30
+ this.updateTimestamp()
31
+
32
+ // Also observe for when the frame appears or changes
33
+ const observer = new MutationObserver(() => {
34
+ const targetFrame = document.getElementById(this.targetFrameValue)
35
+ if (targetFrame) {
36
+ this.updateTimestamp()
37
+ // Watch for attribute changes on the frame
38
+ if (this.frameObserver) {
39
+ this.frameObserver.disconnect()
40
+ }
41
+ this.frameObserver = new MutationObserver(() => {
42
+ this.updateTimestamp()
43
+ })
44
+ this.frameObserver.observe(targetFrame, {
45
+ attributes: true,
46
+ attributeFilter: ['data-cached-at'],
47
+ childList: true,
48
+ subtree: true
49
+ })
50
+ }
51
+ })
52
+
53
+ // Watch the whole document for the frame to appear
54
+ observer.observe(document.body, { childList: true, subtree: true })
55
+ this.documentObserver = observer
56
+ }
57
+ }
58
+
59
+ cachedAtValueChanged() {
60
+ this.updateTimestamp()
61
+ }
62
+
63
+ setupTurboFrameListener() {
64
+ if (this.targetFrameValue) {
65
+ this.turboFrameListener = (event) => {
66
+ // Check if the loaded frame matches our target frame
67
+ if (event.target && event.target.id === this.targetFrameValue) {
68
+ // Update timestamp when our target frame loads
69
+ this.updateTimestamp()
70
+ }
71
+ }
72
+ document.addEventListener('turbo:frame-load', this.turboFrameListener)
73
+ }
74
+ }
75
+
76
+ updateTimestamp() {
77
+ let cachedAtValue = this.cachedAtValue
78
+
79
+ // If no direct cached value but we have a target frame, try to get it from there
80
+ if (!cachedAtValue && this.targetFrameValue) {
81
+ const targetFrame = document.getElementById(this.targetFrameValue)
82
+ if (targetFrame) {
83
+ cachedAtValue = targetFrame.dataset.cachedAt
84
+ }
85
+ }
86
+
87
+ if (cachedAtValue) {
88
+ try {
89
+ const date = new Date(cachedAtValue)
90
+ const localTimeString = date.toLocaleString('en-US', {
91
+ year: 'numeric',
92
+ month: 'long',
93
+ day: 'numeric',
94
+ hour: 'numeric',
95
+ minute: '2-digit',
96
+ hour12: true
97
+ })
98
+ this.element.title = `Last updated: ${localTimeString}`
99
+ } catch (e) {
100
+ this.element.title = 'Cache time unavailable'
101
+ }
102
+ } else {
103
+ this.element.title = 'Cache time unavailable'
104
+ }
105
+ }
106
+ }