rails_pulse 0.3.3 → 0.4.0.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 (169) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +299 -0
  3. data/README.md +44 -3
  4. data/app/assets/stylesheets/rails_pulse/application.css +21 -32
  5. data/app/assets/stylesheets/rails_pulse/components/backtrace.css +238 -0
  6. data/app/assets/stylesheets/rails_pulse/components/badge.css +32 -0
  7. data/app/assets/stylesheets/rails_pulse/components/button.css +2 -36
  8. data/app/assets/stylesheets/rails_pulse/components/card.css +8 -11
  9. data/app/assets/stylesheets/rails_pulse/components/chart.css +16 -3
  10. data/app/assets/stylesheets/rails_pulse/components/csp_safe_positioning.css +5 -39
  11. data/app/assets/stylesheets/rails_pulse/components/dashboard.css +0 -52
  12. data/app/assets/stylesheets/rails_pulse/components/deployments.css +39 -0
  13. data/app/assets/stylesheets/rails_pulse/components/dialog.css +0 -5
  14. data/app/assets/stylesheets/rails_pulse/components/input.css +0 -14
  15. data/app/assets/stylesheets/rails_pulse/components/layouts.css +0 -20
  16. data/app/assets/stylesheets/rails_pulse/components/menu.css +0 -16
  17. data/app/assets/stylesheets/rails_pulse/components/sidebar_menu.css +0 -16
  18. data/app/assets/stylesheets/rails_pulse/components/storage.css +187 -0
  19. data/app/assets/stylesheets/rails_pulse/components/tags.css +0 -14
  20. data/app/assets/stylesheets/rails_pulse/components/time_range_selector.css +5 -5
  21. data/app/assets/stylesheets/rails_pulse/components/utilities.css +0 -17
  22. data/app/controllers/concerns/chart_table_concern.rb +15 -3
  23. data/app/controllers/concerns/ransack_params_concern.rb +18 -0
  24. data/app/controllers/concerns/response_range_concern.rb +1 -1
  25. data/app/controllers/concerns/time_range_concern.rb +28 -14
  26. data/app/controllers/rails_pulse/application_controller.rb +104 -31
  27. data/app/controllers/rails_pulse/dashboard_controller.rb +9 -0
  28. data/app/controllers/rails_pulse/deployments_controller.rb +26 -5
  29. data/app/controllers/rails_pulse/exception_occurrences_controller.rb +19 -0
  30. data/app/controllers/rails_pulse/exceptions_controller.rb +119 -0
  31. data/app/controllers/rails_pulse/job_runs_controller.rb +1 -1
  32. data/app/controllers/rails_pulse/requests_controller.rb +1 -1
  33. data/app/controllers/rails_pulse/routes_controller.rb +7 -1
  34. data/app/controllers/rails_pulse/storage_controller.rb +7 -0
  35. data/app/controllers/rails_pulse/tags_controller.rb +2 -2
  36. data/app/helpers/rails_pulse/application_helper.rb +1 -0
  37. data/app/helpers/rails_pulse/backtrace_helper.rb +85 -0
  38. data/app/helpers/rails_pulse/breadcrumbs_helper.rb +4 -1
  39. data/app/helpers/rails_pulse/chart_helper.rb +0 -3
  40. data/app/helpers/rails_pulse/route_helper.rb +8 -16
  41. data/app/javascript/rails_pulse/application.js +29 -8
  42. data/app/javascript/rails_pulse/controllers/clipboard_controller.js +58 -0
  43. data/app/javascript/rails_pulse/controllers/index_controller.js +16 -10
  44. data/app/javascript/rails_pulse/controllers/pagination_controller.js +6 -2
  45. data/app/javascript/rails_pulse/controllers/popover_controller.js +4 -0
  46. data/app/models/concerns/rails_pulse/taggable.rb +11 -6
  47. data/app/models/rails_pulse/cards/base.rb +53 -0
  48. data/app/models/rails_pulse/dashboard/health_summary.rb +52 -0
  49. data/app/models/rails_pulse/dashboard/needs_attention.rb +73 -1
  50. data/app/models/rails_pulse/dashboard/storage_status.rb +490 -0
  51. data/app/models/rails_pulse/deployment.rb +47 -1
  52. data/app/models/rails_pulse/exception_group.rb +37 -0
  53. data/app/models/rails_pulse/exception_occurrence.rb +27 -0
  54. data/app/models/rails_pulse/exceptions/cards/exception_rate.rb +116 -0
  55. data/app/models/rails_pulse/exceptions/cards/open_groups.rb +49 -0
  56. data/app/models/rails_pulse/exceptions/cards/total_occurrences.rb +62 -0
  57. data/app/models/rails_pulse/exceptions/charts/occurrence_volume.rb +56 -0
  58. data/app/models/rails_pulse/exceptions/raw_data.rb +216 -0
  59. data/app/models/rails_pulse/jobs/cards/p95_duration.rb +8 -2
  60. data/app/models/rails_pulse/operation.rb +13 -4
  61. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +8 -3
  62. data/app/models/rails_pulse/request.rb +5 -1
  63. data/app/models/rails_pulse/route.rb +71 -21
  64. data/app/models/rails_pulse/routes/cards/error_rates.rb +6 -3
  65. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +8 -3
  66. data/app/models/rails_pulse/routes/tables/index.rb +8 -6
  67. data/app/models/rails_pulse/summary.rb +21 -10
  68. data/app/models/rails_pulse/tables/base.rb +4 -2
  69. data/app/services/rails_pulse/analysis/explain_plan_analyzer.rb +47 -12
  70. data/app/services/rails_pulse/exception_capture_service.rb +228 -0
  71. data/app/services/rails_pulse/exception_message_sanitizer.rb +152 -0
  72. data/app/services/rails_pulse/operations/change_point.rb +144 -0
  73. data/app/services/rails_pulse/operations/compare.rb +92 -0
  74. data/app/services/rails_pulse/operations/comparison.rb +126 -0
  75. data/app/services/rails_pulse/operations/metric.rb +63 -0
  76. data/app/services/rails_pulse/operations/series.rb +84 -0
  77. data/app/services/rails_pulse/operations/subject.rb +77 -0
  78. data/app/services/rails_pulse/route_controller_action_backfiller.rb +110 -0
  79. data/app/services/rails_pulse/route_merger.rb +99 -0
  80. data/app/services/rails_pulse/route_migrator.rb +139 -0
  81. data/app/services/rails_pulse/route_path_normalizer.rb +79 -0
  82. data/app/services/rails_pulse/route_recognizer.rb +47 -0
  83. data/app/services/rails_pulse/summary_service.rb +45 -0
  84. data/app/services/rails_pulse/tag_filter_service.rb +5 -3
  85. data/app/views/layouts/rails_pulse/_menu_items.html.erb +7 -0
  86. data/app/views/layouts/rails_pulse/_onboarding_banner.html.erb +8 -0
  87. data/app/views/layouts/rails_pulse/_setup_screen.html.erb +5 -5
  88. data/app/views/layouts/rails_pulse/application.html.erb +4 -4
  89. data/app/views/rails_pulse/components/_empty_state.html.erb +1 -1
  90. data/app/views/rails_pulse/components/_panel.html.erb +16 -6
  91. data/app/views/rails_pulse/components/_raw_data.html.erb +34 -0
  92. data/app/views/rails_pulse/dashboard/_chart_panel.html.erb +6 -8
  93. data/app/views/rails_pulse/dashboard/_deployments_panel.html.erb +64 -0
  94. data/app/views/rails_pulse/dashboard/_health_badge.html.erb +21 -10
  95. data/app/views/rails_pulse/dashboard/_storage_panel.html.erb +67 -0
  96. data/app/views/rails_pulse/dashboard/index.html.erb +22 -1
  97. data/app/views/rails_pulse/deployments/_status_badge.html.erb +5 -0
  98. data/app/views/rails_pulse/deployments/_table.html.erb +29 -0
  99. data/app/views/rails_pulse/deployments/index.html.erb +15 -0
  100. data/app/views/rails_pulse/deployments/show.html.erb +62 -0
  101. data/app/views/rails_pulse/exception_occurrences/show.html.erb +45 -0
  102. data/app/views/rails_pulse/exceptions/_backtrace.html.erb +33 -0
  103. data/app/views/rails_pulse/exceptions/_metric_cards.html.erb +6 -0
  104. data/app/views/rails_pulse/exceptions/_occurrences_table.html.erb +23 -0
  105. data/app/views/rails_pulse/exceptions/_status_badge.html.erb +6 -0
  106. data/app/views/rails_pulse/exceptions/_table.html.erb +33 -0
  107. data/app/views/rails_pulse/exceptions/index.html.erb +66 -0
  108. data/app/views/rails_pulse/exceptions/show.html.erb +52 -0
  109. data/app/views/rails_pulse/queries/_raw_data.html.erb +1 -23
  110. data/app/views/rails_pulse/requests/_table.html.erb +1 -1
  111. data/app/views/rails_pulse/routes/_requests_table.html.erb +5 -0
  112. data/app/views/rails_pulse/routes/_table.html.erb +22 -8
  113. data/app/views/rails_pulse/routes/index.html.erb +5 -1
  114. data/app/views/rails_pulse/routes/show.html.erb +5 -0
  115. data/app/views/rails_pulse/storage/show.html.erb +123 -0
  116. data/config/routes.rb +13 -4
  117. data/db/rails_pulse_migrate/20260506000001_create_rails_pulse_exceptions.rb +50 -0
  118. data/db/rails_pulse_migrate/20260507000001_add_actual_query_to_operations.rb +10 -5
  119. data/db/rails_pulse_migrate/20260610000001_add_controller_action_to_rails_pulse_routes.rb +8 -0
  120. data/db/rails_pulse_migrate/20260610000002_change_rails_pulse_routes_to_multi_verb_model.rb +185 -0
  121. data/db/rails_pulse_migrate/20260610000003_add_null_action_unique_index_to_routes.rb +32 -0
  122. data/db/rails_pulse_migrate/20260823000001_add_location_to_exception_groups.rb +12 -0
  123. data/db/rails_pulse_schema.rb +54 -6
  124. data/lib/generators/rails_pulse/base_methods.rb +2 -0
  125. data/lib/generators/rails_pulse/install_generator.rb +5 -0
  126. data/lib/generators/rails_pulse/schema_parser.rb +22 -2
  127. data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +54 -6
  128. data/lib/generators/rails_pulse/templates/migrations/upgrade_rails_pulse_tables.rb +1 -1
  129. data/lib/generators/rails_pulse/templates/rails_pulse.rb +157 -16
  130. data/lib/generators/rails_pulse/upgrade_generator.rb +166 -38
  131. data/lib/rails_pulse/cleanup_service.rb +110 -15
  132. data/lib/rails_pulse/configuration.rb +125 -6
  133. data/lib/rails_pulse/engine.rb +32 -17
  134. data/lib/rails_pulse/installers/config_updater.rb +202 -0
  135. data/lib/rails_pulse/job_run_collector.rb +43 -16
  136. data/lib/rails_pulse/middleware/asset_server.rb +11 -0
  137. data/lib/rails_pulse/middleware/request_collector.rb +9 -4
  138. data/lib/rails_pulse/packaged_assets.rb +147 -0
  139. data/lib/rails_pulse/route_indexes.rb +66 -0
  140. data/lib/rails_pulse/subscribers/exception_subscriber.rb +36 -0
  141. data/lib/rails_pulse/tracker.rb +29 -10
  142. data/lib/rails_pulse/version.rb +1 -1
  143. data/lib/rails_pulse.rb +1 -0
  144. data/lib/rails_pulse_server.ru +16 -47
  145. data/lib/tasks/rails_pulse.rake +83 -4
  146. data/lib/tasks/rails_pulse_assets.rake +15 -0
  147. data/lib/tasks/rails_pulse_tasks.rake +29 -0
  148. data/public/rails-pulse-assets/rails-pulse-icons.js +6 -2
  149. data/public/rails-pulse-assets/rails-pulse.css +1 -1
  150. data/public/rails-pulse-assets/rails-pulse.js +18 -117
  151. metadata +78 -37
  152. data/Rakefile +0 -554
  153. data/app/javascript/rails_pulse/controllers/form_controller.js +0 -39
  154. data/app/javascript/rails_pulse/controllers/period_selector_controller.js +0 -19
  155. data/app/javascript/rails_pulse/controllers/timezone_controller.js +0 -89
  156. data/app/javascript/rails_pulse/theme.js +0 -416
  157. data/config/brakeman.ignore +0 -190
  158. data/config/brakeman.yml +0 -68
  159. data/config/initializers/rails_pulse.rb +0 -287
  160. data/public/rails-pulse-assets/csp-test.js +0 -120
  161. data/public/rails-pulse-assets/rails-pulse-icons.js.map +0 -13
  162. data/public/rails-pulse-assets/rails-pulse.css.map +0 -1
  163. data/public/rails-pulse-assets/rails-pulse.js.map +0 -7
  164. data/vendor/assets/javascripts/rails-pulse-icons.js +0 -93
  165. data/vendor/assets/javascripts/rails-pulse-icons.js.map +0 -13
  166. data/vendor/assets/javascripts/rails-pulse.js +0 -157
  167. data/vendor/assets/javascripts/rails-pulse.js.map +0 -7
  168. data/vendor/assets/stylesheets/rails-pulse.css +0 -1
  169. data/vendor/assets/stylesheets/rails-pulse.css.map +0 -1
@@ -1,24 +1,48 @@
1
- import * as echarts from "echarts";
2
- import "./theme";
1
+ // Only the chart types and components Rails Pulse actually renders are pulled in.
2
+ // Importing the "echarts" barrel instead would bundle every chart type (sankey,
3
+ // gauge, map, ...) and roughly quintuple the bundle.
4
+ import * as echarts from "echarts/core";
5
+ import { BarChart, LineChart } from "echarts/charts";
6
+ import {
7
+ DataZoomComponent,
8
+ GridComponent,
9
+ LegendComponent,
10
+ MarkAreaComponent,
11
+ MarkLineComponent,
12
+ TooltipComponent
13
+ } from "echarts/components";
14
+ import { CanvasRenderer } from "echarts/renderers";
3
15
  import { Application } from "@hotwired/stimulus";
4
16
 
17
+ // TooltipComponent installs AxisPointer itself, so `axisPointer` options work.
18
+ echarts.use([
19
+ BarChart,
20
+ LineChart,
21
+ DataZoomComponent,
22
+ GridComponent,
23
+ LegendComponent,
24
+ MarkAreaComponent,
25
+ MarkLineComponent,
26
+ TooltipComponent,
27
+ CanvasRenderer
28
+ ]);
29
+
5
30
  // CSS Zero Controllers
6
31
  import ContextMenuController from "./controllers/context_menu_controller";
7
32
  import DatePickerController from "./controllers/datepicker_controller";
8
33
  import DialogController from "./controllers/dialog_controller";
9
34
  import MenuController from "./controllers/menu_controller";
10
35
  import PopoverController from "./controllers/popover_controller";
11
- import FormController from "./controllers/form_controller";
12
36
 
13
37
  // Rails Pulse Controllers
14
38
  import ChartController from "./controllers/chart_controller";
15
39
  import IndexController from "./controllers/index_controller";
16
40
  import ColorSchemeController from "./controllers/color_scheme_controller";
17
41
  import PaginationController from "./controllers/pagination_controller";
18
- import TimezoneController from "./controllers/timezone_controller";
19
42
  import IconController from "./controllers/icon_controller";
20
43
  import ExpandableRowsController from "./controllers/expandable_rows_controller";
21
44
  import CollapsibleController from "./controllers/collapsible_controller";
45
+ import ClipboardController from "./controllers/clipboard_controller";
22
46
  import TableSortController from "./controllers/table_sort_controller";
23
47
  import GlobalFiltersController from "./controllers/global_filters_controller";
24
48
  import CustomRangeController from "./controllers/custom_range_controller"
@@ -26,7 +50,6 @@ import SeriesToggleController from "./controllers/series_toggle_controller";
26
50
  import DeploymentMarkersToggleController from "./controllers/deployment_markers_toggle_controller";
27
51
  import FlameGraphController from "./controllers/flame_graph_controller";
28
52
  import ChartSwitcherController from "./controllers/chart_switcher_controller";
29
- import PeriodSelectorController from "./controllers/period_selector_controller";
30
53
  import TimeRangeController from "./controllers/time_range_controller";
31
54
  const application = Application.start();
32
55
 
@@ -42,16 +65,15 @@ application.register("rails-pulse--datepicker", DatePickerController);
42
65
  application.register("rails-pulse--dialog", DialogController);
43
66
  application.register("rails-pulse--menu", MenuController);
44
67
  application.register("rails-pulse--popover", PopoverController);
45
- application.register("rails-pulse--form", FormController);
46
68
 
47
69
  application.register("rails-pulse--chart", ChartController);
48
70
  application.register("rails-pulse--index", IndexController);
49
71
  application.register("rails-pulse--color-scheme", ColorSchemeController);
50
72
  application.register("rails-pulse--pagination", PaginationController);
51
- application.register("rails-pulse--timezone", TimezoneController);
52
73
  application.register("rails-pulse--icon", IconController);
53
74
  application.register("rails-pulse--expandable-rows", ExpandableRowsController);
54
75
  application.register("rails-pulse--collapsible", CollapsibleController);
76
+ application.register("rails-pulse--clipboard", ClipboardController);
55
77
  application.register("rails-pulse--table-sort", TableSortController);
56
78
  application.register("rails-pulse--global-filters", GlobalFiltersController);
57
79
  application.register("rails-pulse--custom-range", CustomRangeController);
@@ -59,7 +81,6 @@ application.register("rails-pulse--series-toggle", SeriesToggleController);
59
81
  application.register("rails-pulse--deployment-markers-toggle", DeploymentMarkersToggleController);
60
82
  application.register("rails-pulse--flame-graph", FlameGraphController);
61
83
  application.register("rails-pulse--chart-switcher", ChartSwitcherController);
62
- application.register("rails-pulse--period-selector", PeriodSelectorController);
63
84
  application.register("rails-pulse--time-range", TimeRangeController);
64
85
 
65
86
  // Register ECharts theme for Rails Pulse
@@ -0,0 +1,58 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["source"]
5
+ static values = {
6
+ label: { type: String, default: "copy" },
7
+ copiedLabel: { type: String, default: "copied!" },
8
+ errorLabel: { type: String, default: "copy failed" },
9
+ resetDelay: { type: Number, default: 1500 }
10
+ }
11
+
12
+ disconnect() {
13
+ if (this.resetTimeout) clearTimeout(this.resetTimeout)
14
+ }
15
+
16
+ async copy(event) {
17
+ const button = event.currentTarget
18
+ const text = this.hasSourceTarget ? this.sourceTarget.textContent : ""
19
+
20
+ try {
21
+ await this.write(text)
22
+ this.flash(button, this.copiedLabelValue)
23
+ } catch {
24
+ this.flash(button, this.errorLabelValue)
25
+ }
26
+ }
27
+
28
+ // navigator.clipboard is undefined on insecure origins, which is where a
29
+ // mounted dashboard often lives (http://staging.internal). Fall back to the
30
+ // legacy execCommand path there rather than silently doing nothing.
31
+ async write(text) {
32
+ if (navigator.clipboard && window.isSecureContext) {
33
+ return navigator.clipboard.writeText(text)
34
+ }
35
+
36
+ const textarea = document.createElement("textarea")
37
+ textarea.value = text
38
+ textarea.setAttribute("readonly", "")
39
+ textarea.style.position = "fixed"
40
+ textarea.style.opacity = "0"
41
+ document.body.appendChild(textarea)
42
+ textarea.select()
43
+
44
+ try {
45
+ if (!document.execCommand("copy")) throw new Error("execCommand copy failed")
46
+ } finally {
47
+ document.body.removeChild(textarea)
48
+ }
49
+ }
50
+
51
+ flash(button, message) {
52
+ button.textContent = message
53
+ if (this.resetTimeout) clearTimeout(this.resetTimeout)
54
+ this.resetTimeout = setTimeout(() => {
55
+ button.textContent = this.labelValue
56
+ }, this.resetDelayValue)
57
+ }
58
+ }
@@ -40,6 +40,16 @@ export default class extends Controller {
40
40
  this.handleChartInitialized = this.onChartInitialized.bind(this);
41
41
 
42
42
  document.addEventListener('stimulus:echarts:rendered', this.handleChartInitialized);
43
+
44
+ // Releasing the mouse outside the chart still ends a drag-zoom. This is not
45
+ // chart-specific, so it is bound once per controller rather than in
46
+ // setupChartEventListeners — that runs again on every chart switch and used
47
+ // to stack a new document listener each time.
48
+ this.handleDocumentMouseUp = () => {
49
+ if (!this.chart || !this.visibleData) return;
50
+ this.handleZoomChange();
51
+ };
52
+ document.addEventListener('mouseup', this.handleDocumentMouseUp);
43
53
  }
44
54
 
45
55
  disconnect() {
@@ -226,12 +236,6 @@ export default class extends Controller {
226
236
  this.handleZoomChange();
227
237
  });
228
238
 
229
- // When releasing the mouse button outside the chart, we want to check if the visible data has changed
230
- this.handleDocumentMouseUp = () => {
231
- this.handleZoomChange();
232
- };
233
- document.addEventListener('mouseup', this.handleDocumentMouseUp);
234
-
235
239
  // Use ZRender (the underlying canvas renderer) to catch clicks anywhere in the
236
240
  // chart grid — not just on data point markers — then map the pixel position back
237
241
  // to the nearest x-axis bucket so any click in a column triggers filtering.
@@ -398,8 +402,8 @@ export default class extends Controller {
398
402
  }
399
403
 
400
404
  if (this.getAxisType(currentOption) === 'time') {
401
- const startValue = dataZoom.startValue || xAxisData[0];
402
- const endValue = dataZoom.endValue || xAxisData[xAxisData.length - 1];
405
+ const startValue = dataZoom.startValue ?? xAxisData[0];
406
+ const endValue = dataZoom.endValue ?? xAxisData[xAxisData.length - 1];
403
407
 
404
408
  return {
405
409
  xAxis: xAxisData.filter(value => value >= startValue && value <= endValue),
@@ -410,8 +414,10 @@ export default class extends Controller {
410
414
  };
411
415
  }
412
416
 
413
- const startValue = dataZoom.startValue || 0;
414
- const endValue = dataZoom.endValue || xAxisData.length - 1;
417
+ // ?? not ||: index 0 is a valid category position, and `0 || fallback`
418
+ // silently widens the range back to the full axis.
419
+ const startValue = dataZoom.startValue ?? 0;
420
+ const endValue = dataZoom.endValue ?? xAxisData.length - 1;
415
421
 
416
422
  return {
417
423
  xAxis: xAxisData.slice(startValue, endValue + 1),
@@ -28,11 +28,15 @@ export default class extends Controller {
28
28
 
29
29
  // Restore the pagination limit from URL or session storage on page load
30
30
  restorePaginationLimit() {
31
+ // hasLimitTarget, not truthiness: accessing a missing Stimulus target throws
32
+ // rather than returning undefined, which would break connect() outright.
33
+ if (!this.hasLimitTarget) return
34
+
31
35
  // URL params take precedence over session storage
32
36
  const urlParams = new URLSearchParams(window.location.search)
33
37
  const urlLimit = urlParams.get('limit')
34
38
 
35
- if (urlLimit && this.limitTarget) {
39
+ if (urlLimit) {
36
40
  // Sync sessionStorage with URL param
37
41
  sessionStorage.setItem(this.storageKeyValue, urlLimit)
38
42
  if (this.limitTarget.value !== urlLimit) {
@@ -41,7 +45,7 @@ export default class extends Controller {
41
45
  } else {
42
46
  // Fall back to sessionStorage if no URL param
43
47
  const savedLimit = sessionStorage.getItem(this.storageKeyValue)
44
- if (savedLimit && this.limitTarget && this.limitTarget.value !== savedLimit) {
48
+ if (savedLimit && this.limitTarget.value !== savedLimit) {
45
49
  this.limitTarget.value = savedLimit
46
50
  }
47
51
  }
@@ -42,6 +42,10 @@ export default class extends Controller {
42
42
  }
43
43
 
44
44
  disconnect() {
45
+ // A pending debouncedShow would otherwise fire showPopover() on an element
46
+ // that has already been removed (e.g. the table was replaced by a fetch).
47
+ clearTimeout(this.#showTimer)
48
+ clearTimeout(this.#hideTimer)
45
49
  this.cleanup()
46
50
  }
47
51
 
@@ -5,20 +5,25 @@ module RailsPulse
5
5
  # Tag validation constants
6
6
  TAG_NAME_REGEX = /\A[a-z0-9_-]+\z/i
7
7
  MAX_TAG_LENGTH = 50
8
+ # Escape character for LIKE patterns over the serialized tags column.
9
+ # Never appears in a valid tag name, and needs no quoting on any adapter.
10
+ LIKE_ESCAPE = "!".freeze
8
11
 
9
12
  included do
10
13
  # Callbacks
11
14
  before_save :ensure_tags_is_array
12
15
 
13
- # Scopes with table name qualification to avoid ambiguity
14
- # Note: LIKE patterns are sanitized to prevent SQL injection via wildcards
16
+ # Scopes with table name qualification to avoid ambiguity.
17
+ # LIKE patterns are sanitized so `_`/`%` in a tag are matched literally.
18
+ # The ESCAPE clause is explicit because SQLite has no default escape
19
+ # character — without it `my\_tag` matches a literal backslash there
20
+ # and the filter silently matches nothing. `!` is used rather than `\`
21
+ # because MySQL and PostgreSQL disagree on how to quote a backslash.
15
22
  scope :with_tag, ->(tag) {
16
- sanitized_tag = sanitize_sql_like(tag.to_s, "\\")
17
- where("#{table_name}.tags LIKE ?", "%\"#{sanitized_tag}\"%")
23
+ where("#{table_name}.tags LIKE ? ESCAPE '!'", "%\"#{sanitize_sql_like(tag.to_s, LIKE_ESCAPE)}\"%")
18
24
  }
19
25
  scope :without_tag, ->(tag) {
20
- sanitized_tag = sanitize_sql_like(tag.to_s, "\\")
21
- where.not("#{table_name}.tags LIKE ?", "%\"#{sanitized_tag}\"%")
26
+ where.not("#{table_name}.tags LIKE ? ESCAPE '!'", "%\"#{sanitize_sql_like(tag.to_s, LIKE_ESCAPE)}\"%")
22
27
  }
23
28
  scope :with_tags, -> { where("#{table_name}.tags IS NOT NULL AND #{table_name}.tags != '[]'") }
24
29
  end
@@ -173,6 +173,59 @@ module RailsPulse
173
173
  [ icon, format_percentage(percentage.abs, precision) ]
174
174
  end
175
175
 
176
+ # Compares a subject against its own history rather than against the
177
+ # immediately preceding window.
178
+ #
179
+ # The period-over-period arrow above answers "is this week worse than last
180
+ # week?", which is noisy — last week may itself have been unusual. This
181
+ # answers "is this worse than how this normally behaves?", which is the
182
+ # question the number is actually being read for.
183
+ #
184
+ # Only available where there is a single subject to have a history. Index
185
+ # pages aggregate across every route or query, so they keep the
186
+ # period-over-period arrow.
187
+ #
188
+ # @return [Array(String, String, String), nil] icon, amount, caption
189
+ def baseline_trend_for(subject, metric:)
190
+ return nil if subject.nil?
191
+
192
+ comparison = RailsPulse::Operations::Compare.call(subject, metric: metric)
193
+ return nil unless comparison.sufficient_data?
194
+
195
+ percentage = comparison.percent_change
196
+
197
+ icon = if percentage.abs < 0.1
198
+ "move-right"
199
+ elsif percentage.positive?
200
+ "trending-up"
201
+ else
202
+ "trending-down"
203
+ end
204
+
205
+ [ icon, format_percentage(percentage.abs, 1), baseline_caption(comparison) ]
206
+ end
207
+
208
+ # Names the change point only when there is a regression to explain. On a
209
+ # healthy metric "since Aug 26" would be noise, and locating it costs a
210
+ # second query that is not worth spending to say nothing.
211
+ def baseline_caption(comparison)
212
+ caption = "Compared to its #{baseline_window_text} normal"
213
+ return caption unless comparison.regression?
214
+
215
+ change_point = RailsPulse::Operations::ChangePoint.call(
216
+ comparison.subject, metric: comparison.metric
217
+ )
218
+ return caption if change_point.nil?
219
+
220
+ stamp = change_point.hourly? ? change_point.at.strftime("%b %-d %H:%M") : change_point.at.strftime("%b %-d")
221
+ "#{caption} · since #{stamp}"
222
+ end
223
+
224
+ def baseline_window_text
225
+ days = (RailsPulse.configuration.baseline_window / 1.day).round
226
+ "#{days}-day"
227
+ end
228
+
176
229
  # Existing format helpers (keep as-is)
177
230
  def format_percentage(value, precision = 1)
178
231
  "#{value.round(precision)}%"
@@ -11,6 +11,7 @@ module RailsPulse
11
11
  @route_thresholds = RailsPulse.configuration.route_thresholds
12
12
  @query_thresholds = RailsPulse.configuration.query_thresholds
13
13
  @job_thresholds = RailsPulse.configuration.job_thresholds
14
+ @exception_thresholds = RailsPulse.configuration.exception_thresholds
14
15
  end
15
16
 
16
17
  def to_health_data
@@ -18,6 +19,7 @@ module RailsPulse
18
19
  routes: route_counts,
19
20
  queries: query_counts,
20
21
  jobs: job_counts,
22
+ exceptions: exception_counts,
21
23
  storage: storage_counts
22
24
  }
23
25
  end
@@ -108,6 +110,56 @@ module RailsPulse
108
110
 
109
111
  { healthy: healthy, slow: slow, critical: critical }
110
112
  end
113
+
114
+ # Exception groups classified by how often they fired over the dashboard
115
+ # period, read from summaries rather than from ExceptionGroup's lifetime
116
+ # occurrence_count — a group that fired ten thousand times last year and
117
+ # is now silent is not a critical problem today.
118
+ #
119
+ # A group with occurrences in the period but below the warning threshold
120
+ # counts as "slow" rather than "healthy": an exception happening at all is
121
+ # not healthy, it is just not yet urgent.
122
+ def exception_counts
123
+ return nil unless RailsPulse.configuration.track_exceptions
124
+ return nil unless exception_summaries_available?
125
+
126
+ start, finish = period_range
127
+
128
+ counts = RailsPulse::Summary
129
+ .for_exceptions
130
+ .where.not(summarizable_id: 0)
131
+ .where(period_start: start..finish)
132
+ .group(:summarizable_id)
133
+ .sum(:count)
134
+
135
+ healthy = slow = critical = 0
136
+
137
+ open_group_ids.each do |group_id|
138
+ occurrences = counts[group_id].to_i
139
+
140
+ if occurrences >= @exception_thresholds[:critical]
141
+ critical += 1
142
+ elsif occurrences >= @exception_thresholds[:warning]
143
+ slow += 1
144
+ elsif occurrences.positive?
145
+ slow += 1
146
+ else
147
+ healthy += 1
148
+ end
149
+ end
150
+
151
+ { healthy: healthy, slow: slow, critical: critical }
152
+ end
153
+
154
+ def open_group_ids
155
+ RailsPulse::ExceptionGroup.where(status: "open").pluck(:id)
156
+ end
157
+
158
+ def exception_summaries_available?
159
+ RailsPulse::ExceptionGroup.table_exists?
160
+ rescue ActiveRecord::ActiveRecordError
161
+ false
162
+ end
111
163
  end
112
164
  end
113
165
  end
@@ -11,12 +11,13 @@ module RailsPulse
11
11
  @route_thresholds = RailsPulse.configuration.route_thresholds
12
12
  @query_thresholds = RailsPulse.configuration.query_thresholds
13
13
  @job_thresholds = RailsPulse.configuration.job_thresholds
14
+ @exception_thresholds = RailsPulse.configuration.exception_thresholds
14
15
  end
15
16
 
16
17
  MAX_ITEMS = 10
17
18
 
18
19
  def to_attention_data
19
- items = route_items + query_items + job_items
20
+ items = route_items + query_items + job_items + exception_items
20
21
  critical = items.select { |i| i[:severity] == :critical }.sort_by { |i| -i[:sort_score] }
21
22
  warning = items.select { |i| i[:severity] == :warning }.sort_by { |i| -i[:sort_score] }
22
23
 
@@ -247,6 +248,77 @@ module RailsPulse
247
248
  end
248
249
  end
249
250
 
251
+ # Exception groups that fired often enough over the period to be worth
252
+ # looking at. Read from summaries, not from ExceptionGroup#occurrence_count
253
+ # — that is a lifetime counter, so a group that was noisy last year and is
254
+ # silent now would otherwise be reported forever.
255
+ #
256
+ # Ignored groups are excluded by design: a user marking something ignored
257
+ # is saying "stop telling me about this", and the attention list is
258
+ # exactly where that must be honoured.
259
+ def exception_items
260
+ return [] unless RailsPulse.configuration.track_exceptions
261
+ return [] unless exception_tables_available?
262
+
263
+ start, finish = period_range
264
+
265
+ counts = RailsPulse::Summary
266
+ .for_exceptions
267
+ .where.not(summarizable_id: 0)
268
+ .where(period_start: start..finish)
269
+ .group(:summarizable_id)
270
+ .sum(:count)
271
+
272
+ return [] if counts.empty?
273
+
274
+ groups = RailsPulse::ExceptionGroup
275
+ .where(id: counts.keys, status: "open")
276
+ .index_by(&:id)
277
+
278
+ counts.filter_map do |group_id, occurrences|
279
+ group = groups[group_id]
280
+ next if group.nil?
281
+
282
+ severity = exception_severity(occurrences)
283
+ next if severity.nil?
284
+
285
+ {
286
+ type: "EXCEPTION",
287
+ name: group.exception_class,
288
+ reason: "#{occurrences} occurrence#{occurrences == 1 ? "" : "s"}#{group.location.present? ? " · #{group.location}" : ""}",
289
+ metric: "#{occurrences} this period",
290
+ metric_sub: group.last_seen_at ? "last seen #{time_ago_phrase(group.last_seen_at)}" : "open",
291
+ link: url_helpers.exception_path(group.id),
292
+ severity: severity,
293
+ sort_score: occurrences.to_f
294
+ }
295
+ end
296
+ end
297
+
298
+ def exception_severity(occurrences)
299
+ return :critical if occurrences >= @exception_thresholds[:critical]
300
+ return :warning if occurrences >= @exception_thresholds[:warning]
301
+
302
+ nil
303
+ end
304
+
305
+ def time_ago_phrase(time)
306
+ seconds = (Time.current - time).to_i
307
+ return "just now" if seconds < 60
308
+ return "#{seconds / 60}m ago" if seconds < 3600
309
+ return "#{seconds / 3600}h ago" if seconds < 86_400
310
+
311
+ "#{seconds / 86_400}d ago"
312
+ end
313
+
314
+ # The exception tables arrive in a migration, so a host that has upgraded
315
+ # the gem but not yet run migrations must still get a working dashboard.
316
+ def exception_tables_available?
317
+ RailsPulse::ExceptionGroup.table_exists?
318
+ rescue ActiveRecord::ActiveRecordError
319
+ false
320
+ end
321
+
250
322
  def truncate_sql(sql)
251
323
  return "" if sql.blank?
252
324
  cleaned = sql.gsub(/\s+/, " ").strip