rails_pulse 0.4.2 → 0.5.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 (203) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -1
  3. data/README.md +15 -4
  4. data/app/assets/stylesheets/rails_pulse/components/chart.css +11 -0
  5. data/app/assets/stylesheets/rails_pulse/components/descriptive_list.css +19 -1
  6. data/app/assets/stylesheets/rails_pulse/components/row.css +3 -17
  7. data/app/controllers/concerns/chart_table_concern.rb +28 -69
  8. data/app/controllers/concerns/deployment_markers_concern.rb +3 -1
  9. data/app/controllers/concerns/metric_card_concern.rb +6 -7
  10. data/app/controllers/rails_pulse/api/v1/base_controller.rb +133 -0
  11. data/app/controllers/rails_pulse/api/v1/capabilities_controller.rb +27 -0
  12. data/app/controllers/rails_pulse/api/v1/coverage_controller.rb +158 -0
  13. data/app/controllers/rails_pulse/api/v1/deployments_controller.rb +40 -0
  14. data/app/controllers/rails_pulse/api/v1/exceptions_controller.rb +77 -0
  15. data/app/controllers/rails_pulse/api/v1/insights_controller.rb +62 -0
  16. data/app/controllers/rails_pulse/api/v1/job_runs_controller.rb +34 -0
  17. data/app/controllers/rails_pulse/api/v1/jobs_controller.rb +172 -0
  18. data/app/controllers/rails_pulse/api/v1/queries_controller.rb +123 -0
  19. data/app/controllers/rails_pulse/api/v1/requests_controller.rb +74 -0
  20. data/app/controllers/rails_pulse/api/v1/routes_controller.rb +103 -0
  21. data/app/controllers/rails_pulse/application_controller.rb +1 -1
  22. data/app/controllers/rails_pulse/dashboard_controller.rb +25 -21
  23. data/app/controllers/rails_pulse/deployments_controller.rb +23 -13
  24. data/app/controllers/rails_pulse/exceptions_controller.rb +10 -12
  25. data/app/controllers/rails_pulse/jobs_controller.rb +1 -2
  26. data/app/controllers/rails_pulse/queries_controller.rb +6 -5
  27. data/app/controllers/rails_pulse/requests_controller.rb +15 -14
  28. data/app/controllers/rails_pulse/routes_controller.rb +4 -8
  29. data/app/helpers/rails_pulse/application_helper.rb +15 -0
  30. data/app/helpers/rails_pulse/chart_helper.rb +9 -2
  31. data/app/helpers/rails_pulse/formatting_helper.rb +11 -12
  32. data/app/javascript/rails_pulse/controllers/chart_controller.js +80 -43
  33. data/app/jobs/rails_pulse/backfill_summaries_job.rb +4 -2
  34. data/app/jobs/rails_pulse/cloud_health_job.rb +11 -0
  35. data/app/jobs/rails_pulse/cloud_sync_job.rb +11 -0
  36. data/app/jobs/rails_pulse/summary_job.rb +13 -0
  37. data/app/models/concerns/rails_pulse/has_metadata.rb +15 -0
  38. data/app/models/concerns/rails_pulse/taggable.rb +5 -12
  39. data/app/models/rails_pulse/cards/base.rb +20 -11
  40. data/app/models/rails_pulse/charts/base.rb +5 -18
  41. data/app/models/rails_pulse/charts/percentile_chart_base.rb +3 -5
  42. data/app/models/rails_pulse/cleanup_run.rb +32 -0
  43. data/app/models/rails_pulse/cloud/buffered_batch.rb +55 -0
  44. data/app/models/rails_pulse/cloud/installation.rb +55 -0
  45. data/app/models/rails_pulse/dashboard/charts/response_time_percentiles.rb +4 -5
  46. data/app/models/rails_pulse/dashboard/charts/throughput_and_errors.rb +4 -5
  47. data/app/models/rails_pulse/dashboard/concerns/attention_classification.rb +104 -0
  48. data/app/models/rails_pulse/dashboard/concerns/time_range_helper.rb +25 -2
  49. data/app/models/rails_pulse/dashboard/health_summary.rb +38 -13
  50. data/app/models/rails_pulse/dashboard/needs_attention.rb +31 -98
  51. data/app/models/rails_pulse/dashboard/storage_pressure.rb +39 -63
  52. data/app/models/rails_pulse/dashboard/storage_status.rb +130 -39
  53. data/app/models/rails_pulse/deployment.rb +2 -7
  54. data/app/models/rails_pulse/event.rb +41 -0
  55. data/app/models/rails_pulse/exception_occurrence.rb +1 -1
  56. data/app/models/rails_pulse/exceptions/cards/exception_rate.rb +3 -6
  57. data/app/models/rails_pulse/exceptions/cards/open_groups.rb +1 -4
  58. data/app/models/rails_pulse/exceptions/cards/total_occurrences.rb +3 -6
  59. data/app/models/rails_pulse/exceptions/charts/occurrence_volume.rb +4 -7
  60. data/app/models/rails_pulse/job_run.rb +1 -1
  61. data/app/models/rails_pulse/jobs/cards/failure_rate.rb +8 -14
  62. data/app/models/rails_pulse/jobs/cards/p95_duration.rb +8 -14
  63. data/app/models/rails_pulse/jobs/cards/total_runs.rb +5 -11
  64. data/app/models/rails_pulse/jobs/charts/duration.rb +1 -2
  65. data/app/models/rails_pulse/jobs/charts/execution_volume.rb +1 -1
  66. data/app/models/rails_pulse/jobs/charts/failure_rate.rb +1 -2
  67. data/app/models/rails_pulse/like_pattern.rb +35 -0
  68. data/app/models/rails_pulse/queries/cards/average_query_times.rb +6 -12
  69. data/app/models/rails_pulse/queries/cards/database_load.rb +0 -9
  70. data/app/models/rails_pulse/queries/cards/execution_rate.rb +5 -11
  71. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +6 -12
  72. data/app/models/rails_pulse/queries/charts/database_load.rb +5 -8
  73. data/app/models/rails_pulse/queries/charts/execution_volume.rb +1 -1
  74. data/app/models/rails_pulse/request.rb +1 -1
  75. data/app/models/rails_pulse/routes/cards/error_rates.rb +5 -15
  76. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +5 -17
  77. data/app/models/rails_pulse/routes/cards/request_count_totals.rb +5 -17
  78. data/app/models/rails_pulse/routes/charts/error_rate.rb +1 -1
  79. data/app/models/rails_pulse/routes/charts/request_volume.rb +1 -1
  80. data/app/models/rails_pulse/summary.rb +5 -0
  81. data/app/models/rails_pulse/tables/base.rb +5 -6
  82. data/app/models/rails_pulse/time_range.rb +309 -0
  83. data/app/models/rails_pulse/time_window.rb +15 -0
  84. data/app/models/rails_pulse/writer_heartbeat.rb +90 -0
  85. data/app/serializers/rails_pulse/api/v1/deployment_serializer.rb +23 -0
  86. data/app/serializers/rails_pulse/api/v1/exception_group_serializer.rb +23 -0
  87. data/app/serializers/rails_pulse/api/v1/exception_occurrence_serializer.rb +22 -0
  88. data/app/serializers/rails_pulse/api/v1/job_run_serializer.rb +28 -0
  89. data/app/serializers/rails_pulse/api/v1/job_serializer.rb +26 -0
  90. data/app/serializers/rails_pulse/api/v1/query_serializer.rb +25 -0
  91. data/app/serializers/rails_pulse/api/v1/request_serializer.rb +22 -0
  92. data/app/serializers/rails_pulse/api/v1/route_serializer.rb +19 -0
  93. data/app/services/rails_pulse/cloud/batch.rb +83 -0
  94. data/app/services/rails_pulse/cloud/client.rb +68 -0
  95. data/app/services/rails_pulse/cloud/combined_metrics.rb +49 -0
  96. data/app/services/rails_pulse/cloud/health_item.rb +77 -0
  97. data/app/services/rails_pulse/cloud/path_prefix.rb +50 -0
  98. data/app/services/rails_pulse/cloud/preview.rb +83 -0
  99. data/app/services/rails_pulse/cloud/query_shape.rb +195 -0
  100. data/app/services/rails_pulse/cloud/record_items.rb +60 -0
  101. data/app/services/rails_pulse/cloud/route_pattern.rb +111 -0
  102. data/app/services/rails_pulse/cloud/summary_items.rb +188 -0
  103. data/app/services/rails_pulse/cloud/sync.rb +275 -0
  104. data/app/services/rails_pulse/config_recommendations.rb +132 -0
  105. data/app/services/rails_pulse/deployment_comparison.rb +177 -0
  106. data/app/services/rails_pulse/exception_capture_service.rb +1 -1
  107. data/app/services/rails_pulse/operations/change_point.rb +1 -1
  108. data/app/services/rails_pulse/operations/comparison.rb +1 -1
  109. data/app/services/rails_pulse/period_insights.rb +131 -0
  110. data/app/services/rails_pulse/sql_query_normalizer.rb +222 -122
  111. data/app/services/rails_pulse/summary_service/from_child_periods.rb +161 -0
  112. data/app/services/rails_pulse/summary_service/from_raw_rows.rb +115 -0
  113. data/app/services/rails_pulse/summary_service/metrics.rb +38 -0
  114. data/app/services/rails_pulse/summary_service.rb +121 -133
  115. data/app/services/rails_pulse/tag_filter_service.rb +5 -5
  116. data/app/views/layouts/rails_pulse/_time_range_selector.html.erb +4 -1
  117. data/app/views/rails_pulse/components/_operation_details_popover.html.erb +1 -1
  118. data/app/views/rails_pulse/components/_table_head.html.erb +2 -2
  119. data/app/views/rails_pulse/dashboard/_chart_panel.html.erb +6 -1
  120. data/app/views/rails_pulse/dashboard/_deployments_panel.html.erb +2 -2
  121. data/app/views/rails_pulse/dashboard/_health_badge.html.erb +29 -20
  122. data/app/views/rails_pulse/dashboard/_storage_panel.html.erb +13 -3
  123. data/app/views/rails_pulse/dashboard/index.html.erb +16 -2
  124. data/app/views/rails_pulse/jobs/_chart_tabs.html.erb +1 -0
  125. data/app/views/rails_pulse/jobs/index.html.erb +1 -1
  126. data/app/views/rails_pulse/queries/_chart_tabs.html.erb +1 -0
  127. data/app/views/rails_pulse/queries/index.html.erb +1 -1
  128. data/app/views/rails_pulse/queries/show.html.erb +1 -1
  129. data/app/views/rails_pulse/requests/index.html.erb +1 -1
  130. data/app/views/rails_pulse/routes/_chart_tabs.html.erb +1 -0
  131. data/app/views/rails_pulse/routes/index.html.erb +1 -1
  132. data/app/views/rails_pulse/routes/show.html.erb +1 -1
  133. data/app/views/rails_pulse/shared/_aggregation_zone_badge.html.erb +4 -0
  134. data/app/views/rails_pulse/storage/show.html.erb +39 -2
  135. data/config/routes.rb +23 -0
  136. data/db/rails_pulse_cloud_schema.rb +58 -0
  137. data/db/rails_pulse_migrate/20260506000001_create_rails_pulse_exceptions.rb +1 -1
  138. data/db/rails_pulse_migrate/20260921000001_create_rails_pulse_events.rb +21 -0
  139. data/db/rails_pulse_schema.rb +20 -2
  140. data/exe/rails-pulse +7 -0
  141. data/lib/generators/rails_pulse/base_methods.rb +19 -0
  142. data/lib/generators/rails_pulse/install_cloud_generator.rb +84 -0
  143. data/lib/generators/rails_pulse/templates/db/rails_pulse_cloud_schema.rb +58 -0
  144. data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +20 -2
  145. data/lib/generators/rails_pulse/templates/migrations/install_rails_pulse_cloud_tables.rb +30 -0
  146. data/lib/generators/rails_pulse/templates/rails_pulse.rb +71 -19
  147. data/lib/generators/rails_pulse/upgrade_generator.rb +9 -16
  148. data/lib/rails_pulse/cleanup_service.rb +37 -20
  149. data/lib/rails_pulse/cli/agent_files/agents.md +89 -0
  150. data/lib/rails_pulse/cli/agent_files/claude_skill.md +182 -0
  151. data/lib/rails_pulse/cli/base_command.rb +37 -0
  152. data/lib/rails_pulse/cli/client.rb +64 -0
  153. data/lib/rails_pulse/cli/config.rb +87 -0
  154. data/lib/rails_pulse/cli/configure.rb +75 -0
  155. data/lib/rails_pulse/cli/coverage.rb +91 -0
  156. data/lib/rails_pulse/cli/deployments.rb +54 -0
  157. data/lib/rails_pulse/cli/exceptions.rb +116 -0
  158. data/lib/rails_pulse/cli/formatter.rb +31 -0
  159. data/lib/rails_pulse/cli/insights.rb +70 -0
  160. data/lib/rails_pulse/cli/install.rb +145 -0
  161. data/lib/rails_pulse/cli/job_runs.rb +53 -0
  162. data/lib/rails_pulse/cli/jobs.rb +71 -0
  163. data/lib/rails_pulse/cli/main.rb +76 -0
  164. data/lib/rails_pulse/cli/mcp.rb +47 -0
  165. data/lib/rails_pulse/cli/queries.rb +67 -0
  166. data/lib/rails_pulse/cli/requests.rb +57 -0
  167. data/lib/rails_pulse/cli/routes.rb +73 -0
  168. data/lib/rails_pulse/configuration.rb +100 -2
  169. data/lib/rails_pulse/current.rb +11 -0
  170. data/lib/rails_pulse/engine.rb +12 -3
  171. data/lib/rails_pulse/installers/config_updater.rb +9 -2
  172. data/lib/rails_pulse/job_run_collector.rb +15 -15
  173. data/lib/rails_pulse/mcp/server.rb +78 -0
  174. data/lib/rails_pulse/mcp/tools/coverage.rb +96 -0
  175. data/lib/rails_pulse/mcp/tools/deployments.rb +117 -0
  176. data/lib/rails_pulse/mcp/tools/endpoint.rb +167 -0
  177. data/lib/rails_pulse/mcp/tools/errors.rb +104 -0
  178. data/lib/rails_pulse/mcp/tools/exception_detail.rb +118 -0
  179. data/lib/rails_pulse/mcp/tools/exceptions.rb +128 -0
  180. data/lib/rails_pulse/mcp/tools/helpers.rb +162 -0
  181. data/lib/rails_pulse/mcp/tools/insights.rb +99 -0
  182. data/lib/rails_pulse/mcp/tools/jobs.rb +155 -0
  183. data/lib/rails_pulse/mcp/tools/queries.rb +142 -0
  184. data/lib/rails_pulse/mcp/tools/routes.rb +108 -0
  185. data/lib/rails_pulse/mcp/tools/slow_requests.rb +118 -0
  186. data/lib/rails_pulse/middleware/request_collector.rb +9 -9
  187. data/lib/rails_pulse/scoped_inflector.rb +35 -0
  188. data/lib/rails_pulse/statistics.rb +27 -0
  189. data/lib/rails_pulse/subscribers/exception_subscriber.rb +2 -2
  190. data/lib/rails_pulse/subscribers/operation_subscriber.rb +15 -29
  191. data/lib/rails_pulse/tasks/status_reporter.rb +201 -0
  192. data/lib/rails_pulse/tracker.rb +76 -14
  193. data/lib/rails_pulse/version.rb +1 -1
  194. data/lib/rails_pulse.rb +0 -4
  195. data/lib/tasks/rails_pulse.rake +6 -0
  196. data/lib/tasks/rails_pulse_tasks.rake +7 -0
  197. data/public/rails-pulse-assets/rails-pulse.css +1 -1
  198. data/public/rails-pulse-assets/rails-pulse.js +13 -13
  199. metadata +123 -24
  200. data/app/controllers/concerns/page_timings.rb +0 -10
  201. data/app/controllers/concerns/response_range_concern.rb +0 -45
  202. data/app/controllers/concerns/time_range_concern.rb +0 -152
  203. data/app/controllers/concerns/zoom_range_concern.rb +0 -86
@@ -2,10 +2,12 @@ import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  export default class extends Controller {
4
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
5
+ type: String, // "bar", "line", "area", "sparkline"
6
+ data: Object, // Chart data
7
+ options: Object, // ECharts configuration
8
+ theme: String, // ECharts theme
9
+ timezone: String, // IANA zone summaries were bucketed in (e.g. "Etc/UTC")
10
+ timezoneLabel: String // Human label for the same zone (e.g. "UTC")
9
11
  }
10
12
 
11
13
  connect() {
@@ -401,6 +403,46 @@ export default class extends Controller {
401
403
  return this.getSafeFormatter(cleanString)
402
404
  }
403
405
 
406
+ // Formats in the aggregation zone, not the browser's — SummaryJob fixes a
407
+ // day's boundary at write time, so browser-zone display can flip the date.
408
+ formatDailyDate(date, extraOptions = {}) {
409
+ const timeZone = this.timezoneValue || undefined
410
+ return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone, ...extraOptions })
411
+ }
412
+
413
+ // "Mon D, HH:MM" — the hourly-granularity counterpart to formatDailyDate.
414
+ formatDailyDateTime(date) {
415
+ const timeZone = this.timezoneValue || undefined
416
+ return date.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false, timeZone })
417
+ }
418
+
419
+ // "HH:MM" only, same zone as the above.
420
+ formatHourMinute(date) {
421
+ const timeZone = this.timezoneValue || undefined
422
+ return date.toLocaleString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false, timeZone })
423
+ }
424
+
425
+ // Year comparison in the same zone as the label it decorates, so a point
426
+ // near New Year is not tagged with a year the aggregation zone has not
427
+ // reached yet (or has already left).
428
+ isCurrentYear(date) {
429
+ const options = { year: 'numeric', timeZone: this.timezoneValue || undefined }
430
+ return date.toLocaleDateString('en-US', options) === new Date().toLocaleDateString('en-US', options)
431
+ }
432
+
433
+ // Hourly vs. daily comes from the formatter key Ruby chose, not ECharts'
434
+ // generated axisValueLabel (unreliable for some axis-pointer/series combos).
435
+ isHourlyAxis() {
436
+ return this.optionsValue?.xAxis?.axisLabel?.formatter === "time"
437
+ }
438
+
439
+ // Appends " (Zone)" to a tooltip's date/time string when a zone label is
440
+ // available, so a hover always says which zone the point is in.
441
+ withZoneSuffix(dateString) {
442
+ const zone = this.timezoneLabelValue || ''
443
+ return zone ? `${dateString} (${zone})` : dateString
444
+ }
445
+
404
446
  /**
405
447
  * Returns a safe formatter function based on the formatter string.
406
448
  * This prevents arbitrary code execution by using a whitelist approach.
@@ -455,7 +497,7 @@ export default class extends Controller {
455
497
  return value.toString()
456
498
  }
457
499
 
458
- return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
500
+ return this.formatDailyDate(date)
459
501
  }
460
502
  return value
461
503
  },
@@ -463,17 +505,14 @@ export default class extends Controller {
463
505
  // Time only (formatted as "HH:00" to match Rails Pulse formatters)
464
506
  'time': (value) => {
465
507
  if (typeof value === 'number' || typeof value === 'string') {
466
- // Convert to number if string
467
508
  const numValue = typeof value === 'string' ? parseInt(value) : value
468
509
  const date = new Date(numValue)
469
510
 
470
- const hours = date.getHours()
471
- if (isNaN(hours)) {
511
+ if (isNaN(date.getTime())) {
472
512
  return value.toString()
473
513
  }
474
514
 
475
- const minutes = date.getMinutes()
476
- return hours.toString().padStart(2, '0') + ':' + minutes.toString().padStart(2, '0')
515
+ return this.formatHourMinute(date)
477
516
  }
478
517
  return value
479
518
  },
@@ -501,9 +540,7 @@ export default class extends Controller {
501
540
 
502
541
  const data = params[0]
503
542
  const date = new Date(data.axisValue)
504
- const hours = date.getHours().toString().padStart(2, '0')
505
- const minutes = date.getMinutes().toString().padStart(2, '0')
506
- const dateString = `${hours}:${minutes}`
543
+ const dateString = this.withZoneSuffix(this.formatHourMinute(date))
507
544
  const value = parseInt(data.data)
508
545
 
509
546
  return `${dateString} <br /> ${data.marker} ${value} ms`
@@ -515,7 +552,7 @@ export default class extends Controller {
515
552
 
516
553
  const data = params[0]
517
554
  const date = new Date(data.axisValue)
518
- const dateString = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
555
+ const dateString = this.withZoneSuffix(this.formatDailyDate(date))
519
556
  const value = parseInt(data.data)
520
557
 
521
558
  return `${dateString} <br /> ${data.marker} ${value} ms`
@@ -527,9 +564,7 @@ export default class extends Controller {
527
564
 
528
565
  const data = params[0]
529
566
  const date = new Date(data.axisValue)
530
- const hours = date.getHours().toString().padStart(2, '0')
531
- const minutes = date.getMinutes().toString().padStart(2, '0')
532
- const dateString = `${hours}:${minutes}`
567
+ const dateString = this.withZoneSuffix(this.formatHourMinute(date))
533
568
 
534
569
  return `${dateString} <br /> ${data.marker} ${data.data}`
535
570
  },
@@ -540,7 +575,7 @@ export default class extends Controller {
540
575
 
541
576
  const data = params[0]
542
577
  const date = new Date(data.axisValue)
543
- const dateString = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
578
+ const dateString = this.withZoneSuffix(this.formatDailyDate(date))
544
579
 
545
580
  return `${dateString} <br /> ${data.marker} ${data.data}`
546
581
  },
@@ -558,13 +593,14 @@ export default class extends Controller {
558
593
  const timestamp = Number(axisValue)
559
594
  const date = new Date(timestamp)
560
595
  if (!isNaN(date.getTime())) {
561
- dateString = date.toLocaleDateString('en-US', {
562
- month: 'short',
563
- day: 'numeric',
564
- year: date.getFullYear() !== new Date().getFullYear() ? 'numeric' : undefined
596
+ dateString = this.formatDailyDate(date, {
597
+ year: this.isCurrentYear(date) ? undefined : 'numeric'
565
598
  })
566
599
  }
567
600
  }
601
+ // Category axis sends an already-formatted label (e.g. "Apr 28")
602
+ // instead of a timestamp — still needs the suffix below.
603
+ dateString = this.withZoneSuffix(dateString)
568
604
 
569
605
  // Build tooltip HTML with all series
570
606
  let html = `${dateString}<br/>`
@@ -587,7 +623,7 @@ export default class extends Controller {
587
623
  if (typeof numValue === 'number' && !isNaN(numValue) && numValue > 1000000000000) {
588
624
  const date = new Date(numValue)
589
625
  if (!isNaN(date.getTime())) {
590
- return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
626
+ return this.formatDailyDate(date)
591
627
  }
592
628
  }
593
629
 
@@ -599,23 +635,26 @@ export default class extends Controller {
599
635
  'tooltip_with_timestamp': (params) => {
600
636
  if (!Array.isArray(params) || params.length === 0) return ''
601
637
 
602
- // axisValueLabel is the pre-formatted axis label (e.g. "Apr/28" or "04:00")
603
- // axisValue is the raw value — a ms timestamp for time axis, category string otherwise
638
+ // axisValue is the raw value — a ms timestamp for time axis, an
639
+ // already-formatted category string (e.g. "Apr 28") otherwise
604
640
  const axisValue = params[0].axisValue
605
641
  const ts = Number(axisValue)
606
642
  let dateString
607
643
 
608
644
  if (!isNaN(ts) && ts > 1000000000000) {
609
645
  const date = new Date(ts)
610
- // Show time component if sub-day granularity (axis label is HH:mm style)
611
- const axisLabel = params[0].axisValueLabel || ''
612
- const isHourly = /^\d{2}:\d{2}$/.test(axisLabel)
613
- dateString = isHourly
614
- ? date.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false })
615
- : date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
646
+ // Sub-day granularity comes from the formatter Ruby chose for this
647
+ // chart, not from ECharts' own generated axisValueLabel (unreliable
648
+ // for some axis-pointer/series combinations — see isHourlyAxis()).
649
+ dateString = this.isHourlyAxis() ? this.formatDailyDateTime(date) : this.formatDailyDate(date)
616
650
  } else {
651
+ // Category axis (daily/weekly/monthly charts send a plain,
652
+ // already-formatted label like "Apr 28" instead of an epoch
653
+ // timestamp) — still needs the zone suffix, same as the time-axis
654
+ // branch above, or daily-grouped charts silently lose it.
617
655
  dateString = axisValue
618
656
  }
657
+ dateString = this.withZoneSuffix(dateString)
619
658
 
620
659
  // Build tooltip HTML with all series
621
660
  let html = `${dateString}<br/>`
@@ -643,12 +682,13 @@ export default class extends Controller {
643
682
  if (typeof numValue === 'number' && !isNaN(numValue) && numValue > 1000000000000) {
644
683
  const date = new Date(numValue)
645
684
  if (!isNaN(date.getTime())) {
646
- const hours = date.getHours().toString().padStart(2, '0')
647
- const minutes = date.getMinutes().toString().padStart(2, '0')
648
- axisValue = `${hours}:${minutes}`
685
+ axisValue = this.formatHourMinute(date)
649
686
  }
650
687
  }
651
- // If it's already a formatted string (like "Apr 5"), leave it as-is
688
+ // Either the hour just computed above, or an already-formatted daily
689
+ // label (like "Apr 5") — both need the suffix, or daily sparklines
690
+ // silently lose it.
691
+ axisValue = this.withZoneSuffix(axisValue)
652
692
 
653
693
  // Show marker, series name, and value (e.g., "● P95: 150")
654
694
  return `${axisValue}<br/>${data.marker} ${seriesName}: ${value}`
@@ -669,11 +709,10 @@ export default class extends Controller {
669
709
  if (typeof numValue === 'number' && !isNaN(numValue) && numValue > 1000000000000) {
670
710
  const date = new Date(numValue)
671
711
  if (!isNaN(date.getTime())) {
672
- const hours = date.getHours().toString().padStart(2, '0')
673
- const minutes = date.getMinutes().toString().padStart(2, '0')
674
- axisValue = `${hours}:${minutes}`
712
+ axisValue = this.formatHourMinute(date)
675
713
  }
676
714
  }
715
+ axisValue = this.withZoneSuffix(axisValue)
677
716
 
678
717
  return `${axisValue}<br/>${data.marker} ${seriesName}: ${value}`
679
718
  },
@@ -688,14 +727,12 @@ export default class extends Controller {
688
727
 
689
728
  if (!isNaN(ts) && ts > 1000000000000) {
690
729
  const date = new Date(ts)
691
- const axisLabel = params[0].axisValueLabel || ''
692
- const isHourly = /^\d{2}:\d{2}$/.test(axisLabel)
693
- dateString = isHourly
694
- ? date.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false })
695
- : date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
730
+ dateString = this.isHourlyAxis() ? this.formatDailyDateTime(date) : this.formatDailyDate(date)
696
731
  } else {
732
+ // Category axis (daily/weekly/monthly) — see tooltip_with_timestamp.
697
733
  dateString = axisValue
698
734
  }
735
+ dateString = this.withZoneSuffix(dateString)
699
736
 
700
737
  let html = `${dateString}<br/>`
701
738
  params.forEach(param => {
@@ -6,8 +6,10 @@ module RailsPulse
6
6
  # @param period_types [Array<String>] Period types to backfill (default: ["hour", "day"])
7
7
  # @return [nil]
8
8
  def perform(start_date, end_date, period_types = [ "hour", "day" ])
9
- start_date = start_date.to_datetime
10
- end_date = end_date.to_datetime
9
+ # A Date or date string means midnight in the aggregation time zone, so
10
+ # backfilled periods land on the boundaries the scheduled job writes.
11
+ start_date = start_date.in_time_zone
12
+ end_date = end_date.in_time_zone
11
13
 
12
14
  period_types.each do |period_type|
13
15
  backfill_period(period_type, start_date, end_date)
@@ -0,0 +1,11 @@
1
+ module RailsPulse
2
+ # Sends the health update for the minute just ended to Rails Pulse Cloud,
3
+ # deployments recorded since the last run, and any buffered batches whose
4
+ # retry is due. Schedule it every minute alongside SummaryJob. Does nothing
5
+ # unless config.cloud is set.
6
+ class CloudHealthJob < ApplicationJob
7
+ def perform
8
+ Cloud::Sync.new.minutely
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ module RailsPulse
2
+ # Sends newly summarized hours, changed exception groups and deployments to
3
+ # Rails Pulse Cloud. SummaryJob enqueues it once its summaries are written,
4
+ # so it needs no schedule of its own. Does nothing unless config.cloud is
5
+ # set.
6
+ class CloudSyncJob < ApplicationJob
7
+ def perform
8
+ Cloud::Sync.new.hourly
9
+ end
10
+ end
11
+ end
@@ -19,6 +19,8 @@ module RailsPulse
19
19
  # Check if we should run monthly summary (first day of month)
20
20
  process_monthly_summary((target_hour.to_date - 1.month).beginning_of_month) if first_of_month?(target_hour)
21
21
  end
22
+
23
+ enqueue_cloud_sync
22
24
  rescue StandardError => e
23
25
  log_error(e)
24
26
  raise
@@ -26,6 +28,17 @@ module RailsPulse
26
28
 
27
29
  private
28
30
 
31
+ # Cloud receives an hour only once it is summarized, so the sync follows
32
+ # this job rather than running on a schedule of its own. A queue that
33
+ # refuses the job must not fail the summaries already written.
34
+ def enqueue_cloud_sync
35
+ return unless RailsPulse.configuration.cloud.enabled?
36
+
37
+ CloudSyncJob.perform_later
38
+ rescue StandardError => e
39
+ RailsPulse.logger.error "Could not enqueue the Rails Pulse Cloud sync: #{e.class} - #{e.message}"
40
+ end
41
+
29
42
  def midnight?(time)
30
43
  time.hour == 0
31
44
  end
@@ -0,0 +1,15 @@
1
+ module RailsPulse
2
+ module HasMetadata
3
+ extend ActiveSupport::Concern
4
+
5
+ # metadata can be valid JSON that isn't an object (an array, a scalar)
6
+ def metadata_hash
7
+ return {} if metadata.blank?
8
+
9
+ parsed = JSON.parse(metadata)
10
+ parsed.is_a?(Hash) ? parsed : {}
11
+ rescue JSON::ParserError
12
+ {}
13
+ end
14
+ end
15
+ end
@@ -5,25 +5,18 @@ 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
11
-
12
8
  included do
13
9
  # Callbacks
14
10
  before_save :ensure_tags_is_array
15
11
 
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.
12
+ # Scopes with table name qualification to avoid ambiguity. Tags are
13
+ # matched against the serialized column, so the quoted tag name is
14
+ # escaped by LikePattern and paired with its ESCAPE clause.
22
15
  scope :with_tag, ->(tag) {
23
- where("#{table_name}.tags LIKE ? ESCAPE '!'", "%\"#{sanitize_sql_like(tag.to_s, LIKE_ESCAPE)}\"%")
16
+ where("#{table_name}.tags LIKE ? #{RailsPulse::LikePattern::CLAUSE}", "%\"#{RailsPulse::LikePattern.escape(tag)}\"%")
24
17
  }
25
18
  scope :without_tag, ->(tag) {
26
- where.not("#{table_name}.tags LIKE ? ESCAPE '!'", "%\"#{sanitize_sql_like(tag.to_s, LIKE_ESCAPE)}\"%")
19
+ where.not("#{table_name}.tags LIKE ? #{RailsPulse::LikePattern::CLAUSE}", "%\"#{RailsPulse::LikePattern.escape(tag)}\"%")
27
20
  }
28
21
  scope :with_tags, -> { where("#{table_name}.tags IS NOT NULL AND #{table_name}.tags != '[]'") }
29
22
  end
@@ -3,14 +3,21 @@ require "active_support/number_helper"
3
3
  module RailsPulse
4
4
  module Cards
5
5
  class Base
6
+ def initialize(window: nil, subject: nil, period: 7, period_type: "day", disabled_tags: [], show_non_tagged: true)
7
+ @window = window
8
+ @subject = subject
9
+ @period = period
10
+ @period_type = period_type
11
+ @disabled_tags = disabled_tags
12
+ @show_non_tagged = show_non_tagged
13
+ end
14
+
6
15
  private
7
16
 
8
- # nil unless the caller passed both start_time and end_time, so the
9
- # "trailing @period days/hours" fallback below still applies by default.
17
+ # nil unless the caller passed a window, so the "trailing @period
18
+ # days/hours" fallback below still applies by default.
10
19
  def time_window
11
- return @time_window if defined?(@time_window)
12
-
13
- @time_window = RailsPulse::TimeWindow.build(@start_time, @end_time)
20
+ @window
14
21
  end
15
22
 
16
23
  def now
@@ -74,14 +81,16 @@ module RailsPulse
74
81
  end
75
82
 
76
83
  def subject_id
77
- # Subclasses can override to provide @job.id, @query.id, @route.id
78
- instance_variable_get(:@job)&.id ||
79
- instance_variable_get(:@query)&.id ||
80
- instance_variable_get(:@route)&.id
84
+ @subject&.id
81
85
  end
82
86
 
83
- def quote(time)
84
- RailsPulse::Summary.connection.quote(time)
87
+ # A SELECT fragment comparing against the card's window, with the
88
+ # bounds bound as values (:current_start, :range_start) rather than
89
+ # interpolated into the SQL string.
90
+ def window_sql(fragment)
91
+ Arel.sql(
92
+ RailsPulse::Summary.sanitize_sql_array([ fragment, { current_start: current_window_start, range_start: range_start } ])
93
+ )
85
94
  end
86
95
 
87
96
  # Enhanced sparkline generation (support hour and day)
@@ -1,18 +1,14 @@
1
1
  module RailsPulse
2
2
  module Charts
3
3
  class Base
4
- def initialize(ransack_query:, period_type: nil, subject: nil, start_time: nil, end_time: nil, start_duration: nil, disabled_tags: [], show_non_tagged: true, **options)
4
+ def initialize(ransack_query:, period_type: nil, subject: nil, window: nil, start_duration: nil, disabled_tags: [], show_non_tagged: true)
5
5
  @ransack_query = ransack_query
6
6
  @period_type = period_type
7
7
  @subject = subject
8
- @start_time = start_time
9
- @end_time = end_time
8
+ @window = window
10
9
  @start_duration = start_duration
11
10
  @disabled_tags = disabled_tags
12
11
  @show_non_tagged = show_non_tagged
13
-
14
- # Support legacy parameter names (job, query, route)
15
- @subject ||= options[:job] || options[:query] || options[:route]
16
12
  end
17
13
 
18
14
  private
@@ -28,23 +24,14 @@ module RailsPulse
28
24
  .then { |q| @subject ? q.where(summarizable_id: @subject.id) : q }
29
25
  end
30
26
 
31
- # Common helper for time step calculation
32
- def time_step
33
- @period_type.to_s == "hour" ? 3600 : 86400
34
- end
35
-
36
27
  # Abstract method - must be implemented by subclasses
37
28
  def summarizable_type
38
29
  raise NotImplementedError, "#{self.class} must implement summarizable_type"
39
30
  end
40
31
 
41
- # Common helper for padding missing data points with zeros
42
- def pad_data_with_zeros(raw_data, start_time, end_time, step)
43
- {}.tap do |padded|
44
- (start_time.to_i..end_time.to_i).step(step) do |timestamp|
45
- padded[timestamp] = raw_data[timestamp] || 0
46
- end
47
- end
32
+ # Every bucket in the window, with zero where there is no data.
33
+ def pad_data_with_zeros(raw_data)
34
+ @window.bucket_timestamps(@period_type).index_with { |timestamp| raw_data[timestamp] || 0 }
48
35
  end
49
36
  end
50
37
  end
@@ -1,12 +1,11 @@
1
1
  module RailsPulse
2
2
  module Charts
3
3
  class PercentileChartBase
4
- def initialize(ransack_query:, period_type: nil, subject: nil, start_time: nil, end_time: nil, start_duration: nil, disabled_tags: [], show_non_tagged: true)
4
+ def initialize(ransack_query:, period_type: nil, subject: nil, window: nil, start_duration: nil, disabled_tags: [], show_non_tagged: true)
5
5
  @ransack_query = ransack_query
6
6
  @period_type = period_type
7
7
  @subject = subject
8
- @start_time = start_time
9
- @end_time = end_time
8
+ @window = window
10
9
  @start_duration = start_duration
11
10
  @disabled_tags = disabled_tags
12
11
  @show_non_tagged = show_non_tagged
@@ -48,9 +47,8 @@ module RailsPulse
48
47
  end
49
48
 
50
49
  # Convert to final values (weighted averages) and pad missing data
51
- step = @period_type.to_s == "hour" ? 3600 : 86400
52
50
  daily_data = {}
53
- (@start_time.to_i..@end_time.to_i).step(step) do |timestamp|
51
+ @window.bucket_timestamps(@period_type).each do |timestamp|
54
52
  if raw_data[timestamp]
55
53
  count = raw_data[timestamp][:total_count]
56
54
  daily_data[timestamp] = {
@@ -0,0 +1,32 @@
1
+ module RailsPulse
2
+ # One CleanupService run, stored as an Event of kind cleanup_run: `value`
3
+ # is the rows it deleted, `outcome` completed or failed, and `metadata` the
4
+ # per-table counts and any stages that failed. rails_pulse:status reads the
5
+ # latest to say whether cleanup is running at all; nothing else depends on
6
+ # these rows, so event retention prunes them like any other kind.
7
+ class CleanupRun
8
+ KIND = "cleanup_run".freeze
9
+ SUBJECT = "cleanup".freeze
10
+
11
+ class << self
12
+ def events
13
+ RailsPulse::Event.of_kind(KIND)
14
+ end
15
+
16
+ def record!(stats:, failed_stages: [], ran_at: Time.current)
17
+ RailsPulse::Event.create!(
18
+ kind: KIND,
19
+ subject: SUBJECT,
20
+ outcome: failed_stages.any? ? "failed" : "completed",
21
+ value: stats[:total_deleted].to_i,
22
+ occurred_at: ran_at,
23
+ metadata: { time_based: stats[:time_based], count_based: stats[:count_based], failed_stages: failed_stages }.to_json
24
+ )
25
+ end
26
+
27
+ def latest
28
+ events.recent.first
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,55 @@
1
+ require "zlib"
2
+
3
+ module RailsPulse
4
+ module Cloud
5
+ # A batch waiting to be sent to Rails Pulse Cloud, stored gzipped. It
6
+ # keeps its batch_id across retries, so Cloud recognises a resend. The
7
+ # buffer holds at most MAX_AGE or MAX_BYTES of batches, dropping the
8
+ # oldest first, so an outage cannot grow it without bound.
9
+ class BufferedBatch < RailsPulse::ApplicationRecord
10
+ self.table_name = "rails_pulse_cloud_batches"
11
+
12
+ MAX_AGE = 7.days
13
+ MAX_BYTES = 50.megabytes
14
+
15
+ validates :batch_id, :payload, :byte_size, :item_count, :next_attempt_at, presence: true
16
+
17
+ scope :due, ->(now = Time.current) { where(next_attempt_at: ..now).order(:created_at, :id) }
18
+
19
+ def self.ransackable_attributes(_auth_object = nil)
20
+ %w[batch_id byte_size item_count attempts next_attempt_at created_at]
21
+ end
22
+
23
+ def self.ransackable_associations(_auth_object = nil)
24
+ []
25
+ end
26
+
27
+ def self.enqueue!(batch, now: Time.current)
28
+ payload = Zlib.gzip(batch.to_json)
29
+ create!(batch_id: batch.batch_id, payload: payload, byte_size: payload.bytesize, item_count: batch.items.size,
30
+ next_attempt_at: now)
31
+ end
32
+
33
+ # Drops batches past MAX_AGE, then the oldest until the rest fit in
34
+ # MAX_BYTES. Returns how many were dropped.
35
+ def self.prune!(now = Time.current)
36
+ dropped = where(created_at: ...(now - MAX_AGE)).delete_all
37
+ total = sum(:byte_size)
38
+ return dropped if total <= MAX_BYTES
39
+
40
+ order(:created_at, :id).pluck(:id, :byte_size).each do |id, size|
41
+ break if total <= MAX_BYTES
42
+
43
+ dropped += where(id: id).delete_all
44
+ total -= size
45
+ end
46
+ dropped
47
+ end
48
+
49
+ # The stored batch as a hash with string keys.
50
+ def contents
51
+ JSON.parse(Zlib.gunzip(payload))
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,55 @@
1
+ module RailsPulse
2
+ module Cloud
3
+ # The one row naming this Rails Pulse database to Rails Pulse Cloud, and
4
+ # where its sync has got to. Hosts that share the database share the row,
5
+ # so their data is counted once; hosts with separate databases are
6
+ # separate installations, and Cloud adds them together.
7
+ class Installation < RailsPulse::ApplicationRecord
8
+ self.table_name = "rails_pulse_cloud_installations"
9
+
10
+ validates :installation_id, presence: true
11
+
12
+ def self.ransackable_attributes(_auth_object = nil)
13
+ %w[installation_id last_success_at last_error_at paused_until created_at]
14
+ end
15
+
16
+ def self.ransackable_associations(_auth_object = nil)
17
+ []
18
+ end
19
+
20
+ # The row, created with a new installation ID the first time it is
21
+ # asked for. A second process creating it at the same moment loses on
22
+ # the unique index and reads the winner's.
23
+ def self.current
24
+ first || create!(installation_id: SecureRandom.uuid)
25
+ rescue ActiveRecord::RecordNotUnique
26
+ first!
27
+ end
28
+
29
+ # The row if one exists, without creating it.
30
+ def self.existing
31
+ first
32
+ rescue ActiveRecord::ActiveRecordError
33
+ nil
34
+ end
35
+
36
+ def paused?(now = Time.current)
37
+ paused_until.present? && paused_until > now
38
+ end
39
+
40
+ def record_success!(deprecated_on: nil, now: Time.current)
41
+ update!(last_success_at: now, last_error: nil, last_error_at: nil, paused_until: nil, pause_reason: nil,
42
+ contract_deprecated_on: deprecated_on)
43
+ end
44
+
45
+ def record_error!(message, now: Time.current)
46
+ update!(last_error: message.to_s.truncate(2_000), last_error_at: now)
47
+ end
48
+
49
+ def pause!(until_time, reason, now: Time.current)
50
+ update!(paused_until: until_time, pause_reason: reason.to_s.truncate(2_000),
51
+ last_error: reason.to_s.truncate(2_000), last_error_at: now)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -2,13 +2,12 @@ module RailsPulse
2
2
  module Dashboard
3
3
  module Charts
4
4
  class ResponseTimePercentiles
5
- def initialize(disabled_tags: [], show_non_tagged: true, period: 7, period_type: "day", start_time: nil, end_time: nil)
5
+ def initialize(disabled_tags: [], show_non_tagged: true, period: 7, period_type: "day", window: nil)
6
6
  @disabled_tags = disabled_tags
7
7
  @show_non_tagged = show_non_tagged
8
8
  @period = period
9
9
  @period_type = period_type
10
- @start_time = start_time
11
- @end_time = end_time
10
+ @window = window
12
11
  end
13
12
 
14
13
  def to_chart_data
@@ -169,10 +168,10 @@ module RailsPulse
169
168
 
170
169
  private
171
170
 
172
- # nil unless both start_time and end_time were given, so the default
171
+ # nil unless a window was given, so the default
173
172
  # "recent" view keeps using the trailing-@period-days fallback below.
174
173
  def time_window
175
- @time_window ||= RailsPulse::TimeWindow.build(@start_time, @end_time)
174
+ @window
176
175
  end
177
176
 
178
177
  def default_hour_range