railwatch 0.1.4 → 0.2.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 (302) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +212 -0
  3. data/README.md +10 -0
  4. data/app/channels/railwatch/environment_channel.rb +28 -0
  5. data/app/controllers/concerns/railwatch/telemetry_identity.rb +25 -0
  6. data/app/controllers/railwatch/alerts_controller.rb +48 -0
  7. data/app/controllers/railwatch/anomaly_rules_controller.rb +31 -0
  8. data/app/controllers/railwatch/attachments_controller.rb +22 -0
  9. data/app/controllers/railwatch/beacon_controller.rb +67 -7
  10. data/app/controllers/railwatch/broadcasts_controller.rb +15 -0
  11. data/app/controllers/railwatch/cache_events_controller.rb +30 -0
  12. data/app/controllers/railwatch/commands_controller.rb +16 -0
  13. data/app/controllers/railwatch/comments_controller.rb +11 -0
  14. data/app/controllers/railwatch/dashboard_controller.rb +69 -0
  15. data/app/controllers/railwatch/deploys_controller.rb +34 -0
  16. data/app/controllers/railwatch/deprecations_controller.rb +54 -0
  17. data/app/controllers/railwatch/environment_scoped.rb +99 -0
  18. data/app/controllers/railwatch/exceptions_controller.rb +53 -0
  19. data/app/controllers/railwatch/executions_controller.rb +13 -0
  20. data/app/controllers/railwatch/issues_controller.rb +258 -0
  21. data/app/controllers/railwatch/jobs_controller.rb +63 -0
  22. data/app/controllers/railwatch/llm_calls_controller.rb +124 -0
  23. data/app/controllers/railwatch/logs_controller.rb +40 -0
  24. data/app/controllers/railwatch/mails_controller.rb +19 -0
  25. data/app/controllers/railwatch/notifications_controller.rb +11 -0
  26. data/app/controllers/railwatch/outgoing_requests_controller.rb +19 -0
  27. data/app/controllers/railwatch/overview_controller.rb +38 -0
  28. data/app/controllers/railwatch/people_controller.rb +25 -0
  29. data/app/controllers/railwatch/processes_controller.rb +33 -0
  30. data/app/controllers/railwatch/profiles_controller.rb +50 -0
  31. data/app/controllers/railwatch/queries_controller.rb +59 -0
  32. data/app/controllers/railwatch/releases_controller.rb +75 -0
  33. data/app/controllers/railwatch/requests_controller.rb +55 -0
  34. data/app/controllers/railwatch/saved_views_controller.rb +53 -0
  35. data/app/controllers/railwatch/scheduled_tasks_controller.rb +45 -0
  36. data/app/controllers/railwatch/spans_controller.rb +47 -0
  37. data/app/controllers/railwatch/storage_ops_controller.rb +15 -0
  38. data/app/controllers/railwatch/tenants_controller.rb +44 -0
  39. data/app/controllers/railwatch/thresholds_controller.rb +40 -0
  40. data/app/controllers/railwatch/traces_controller.rb +72 -0
  41. data/app/controllers/railwatch/transactions_controller.rb +21 -0
  42. data/app/controllers/railwatch/view_renders_controller.rb +28 -0
  43. data/app/controllers/railwatch/visits_controller.rb +53 -0
  44. data/app/helpers/railwatch/assets_helper.rb +52 -0
  45. data/app/jobs/railwatch/anomaly_scan_job.rb +11 -0
  46. data/app/jobs/railwatch/application_job.rb +7 -0
  47. data/app/jobs/railwatch/auto_resolve_issues_job.rb +19 -0
  48. data/app/jobs/railwatch/check_scheduled_tasks_job.rb +113 -0
  49. data/app/jobs/railwatch/detect_anomalies_job.rb +171 -0
  50. data/app/jobs/railwatch/detect_performance_issues_job.rb +85 -0
  51. data/app/jobs/railwatch/group_exceptions_job.rb +91 -0
  52. data/app/jobs/railwatch/optimize_telemetry_job.rb +23 -0
  53. data/app/jobs/railwatch/performance_scan_job.rb +11 -0
  54. data/app/jobs/railwatch/prune_telemetry_job.rb +110 -0
  55. data/app/jobs/railwatch/release_health_rollup_job.rb +64 -0
  56. data/app/jobs/railwatch/rollup_catchup_job.rb +21 -0
  57. data/app/jobs/railwatch/rollup_job.rb +130 -0
  58. data/app/jobs/railwatch/scheduled_task_scan_job.rb +11 -0
  59. data/app/models/concerns/railwatch/detection_snapshotting.rb +20 -0
  60. data/app/models/railwatch/alert.rb +229 -0
  61. data/app/models/railwatch/alert_rule.rb +121 -0
  62. data/app/models/railwatch/anomaly_rule.rb +33 -0
  63. data/app/models/railwatch/application.rb +44 -0
  64. data/app/models/railwatch/application_record.rb +33 -0
  65. data/app/models/railwatch/comment.rb +36 -0
  66. data/app/models/railwatch/deploy.rb +67 -0
  67. data/app/models/railwatch/environment.rb +54 -0
  68. data/app/models/railwatch/execution_presenter.rb +185 -0
  69. data/app/models/railwatch/filter_query.rb +143 -0
  70. data/app/models/railwatch/followup_receipt.rb +27 -0
  71. data/app/models/railwatch/ingest/batch.rb +305 -0
  72. data/app/models/railwatch/ingest/mapper.rb +575 -0
  73. data/app/models/railwatch/ingest/payload.rb +96 -0
  74. data/app/models/railwatch/ingest/rollup_absorber.rb +170 -0
  75. data/app/models/railwatch/ingest/writer.rb +137 -0
  76. data/app/models/railwatch/issue.rb +277 -0
  77. data/app/models/railwatch/issue_activity.rb +23 -0
  78. data/app/models/railwatch/issue_detection_presenter.rb +309 -0
  79. data/app/models/railwatch/issue_detection_snapshot.rb +77 -0
  80. data/app/models/railwatch/maintenance_task.rb +53 -0
  81. data/app/models/railwatch/saved_view.rb +63 -0
  82. data/app/models/railwatch/telemetry/aggregations.rb +116 -0
  83. data/app/models/railwatch/telemetry/attachment.rb +31 -0
  84. data/app/models/railwatch/telemetry/bounded_gzip.rb +101 -0
  85. data/app/models/railwatch/telemetry/broadcast.rb +13 -0
  86. data/app/models/railwatch/telemetry/cache_event.rb +16 -0
  87. data/app/models/railwatch/telemetry/child.rb +53 -0
  88. data/app/models/railwatch/telemetry/cursor_page.rb +99 -0
  89. data/app/models/railwatch/telemetry/deprecation.rb +13 -0
  90. data/app/models/railwatch/telemetry/enqueued_job.rb +13 -0
  91. data/app/models/railwatch/telemetry/exception.rb +21 -0
  92. data/app/models/railwatch/telemetry/execution.rb +71 -0
  93. data/app/models/railwatch/telemetry/health_sample.rb +72 -0
  94. data/app/models/railwatch/telemetry/ingest_batch.rb +31 -0
  95. data/app/models/railwatch/telemetry/llm_call.rb +37 -0
  96. data/app/models/railwatch/telemetry/log.rb +85 -0
  97. data/app/models/railwatch/telemetry/mail.rb +13 -0
  98. data/app/models/railwatch/telemetry/n_plus_one.rb +92 -0
  99. data/app/models/railwatch/telemetry/notification.rb +13 -0
  100. data/app/models/railwatch/telemetry/outgoing_request.rb +13 -0
  101. data/app/models/railwatch/telemetry/person.rb +50 -0
  102. data/app/models/railwatch/telemetry/process.rb +10 -0
  103. data/app/models/railwatch/telemetry/profile.rb +37 -0
  104. data/app/models/railwatch/telemetry/query.rb +50 -0
  105. data/app/models/railwatch/telemetry/query_shape.rb +45 -0
  106. data/app/models/railwatch/telemetry/release_health.rb +63 -0
  107. data/app/models/railwatch/telemetry/rollup.rb +78 -0
  108. data/app/models/railwatch/telemetry/session.rb +24 -0
  109. data/app/models/railwatch/telemetry/span.rb +19 -0
  110. data/app/models/railwatch/telemetry/storage_op.rb +13 -0
  111. data/app/models/railwatch/telemetry/tenant.rb +221 -0
  112. data/app/models/railwatch/telemetry/transaction.rb +13 -0
  113. data/app/models/railwatch/telemetry/view_render.rb +13 -0
  114. data/app/models/railwatch/telemetry/visit.rb +24 -0
  115. data/app/models/railwatch/telemetry_record.rb +57 -0
  116. data/app/models/railwatch/threshold.rb +29 -0
  117. data/app/models/railwatch/user.rb +47 -0
  118. data/app/models/railwatch/viewer.rb +13 -0
  119. data/app/views/layouts/railwatch/dashboard.html.erb +25 -0
  120. data/config/routes.rb +59 -0
  121. data/db/railwatch_migrate/20260916000000_create_railwatch_tables.rb +151 -0
  122. data/db/railwatch_migrate/20260917000000_create_railwatch_maintenance_tasks.rb +18 -0
  123. data/db/railwatch_migrate/20260917120000_create_railwatch_followup_receipts.rb +20 -0
  124. data/db/railwatch_migrate/20260918120000_widen_host_user_ids.rb +71 -0
  125. data/db/railwatch_telemetry_migrate/20260903000001_create_telemetry.rb +481 -0
  126. data/db/railwatch_telemetry_migrate/20260903000002_rename_tenant_to_app_tenant.rb +14 -0
  127. data/db/railwatch_telemetry_migrate/20260903000003_add_statement_count_to_transactions.rb +9 -0
  128. data/db/railwatch_telemetry_migrate/20260903000004_add_role_and_channel.rb +10 -0
  129. data/db/railwatch_telemetry_migrate/20260903000005_add_locals_to_exceptions.rb +9 -0
  130. data/db/railwatch_telemetry_migrate/20260903000006_add_spans_health_vitals_and_fts.rb +82 -0
  131. data/db/railwatch_telemetry_migrate/20260903000007_rename_span_attributes_to_payload.rb +10 -0
  132. data/db/railwatch_telemetry_migrate/20260903000008_add_profiles_and_attachments.rb +60 -0
  133. data/db/railwatch_telemetry_migrate/20260903000009_add_truncated_to_attachments.rb +9 -0
  134. data/db/railwatch_telemetry_migrate/20260903000010_create_sessions_and_release_health.rb +51 -0
  135. data/db/railwatch_telemetry_migrate/20260903000011_add_fingerprint_to_exceptions.rb +11 -0
  136. data/db/railwatch_telemetry_migrate/20260904000012_add_failed_to_broadcasts.rb +10 -0
  137. data/db/railwatch_telemetry_migrate/20260904010000_add_filter_cursor_indexes.rb +37 -0
  138. data/db/railwatch_telemetry_migrate/20260904020000_add_n_plus_ones_execution_id_index.rb +11 -0
  139. data/db/railwatch_telemetry_migrate/20260904120000_create_query_shapes.rb +15 -0
  140. data/db/railwatch_telemetry_migrate/20260906120000_add_backpressure_factor_to_ingest_batches.rb +9 -0
  141. data/db/railwatch_telemetry_migrate/20260907000000_rename_lantern_version_on_processes.rb +10 -0
  142. data/db/railwatch_telemetry_migrate/20260913000000_rename_nightrail_version_on_processes.rb +9 -0
  143. data/db/railwatch_telemetry_migrate/20260914000000_drop_orphan_durable_ingest_tables.rb +18 -0
  144. data/db/railwatch_telemetry_migrate/20260914010000_drop_orphan_durable_ingest_columns.rb +25 -0
  145. data/db/railwatch_telemetry_migrate/20260915000000_create_llm_calls.rb +55 -0
  146. data/db/railwatch_telemetry_migrate/20260915120000_add_detail_to_llm_calls.rb +21 -0
  147. data/db/railwatch_telemetry_migrate/20260915200000_add_explained_index_to_queries.rb +16 -0
  148. data/db/railwatch_telemetry_migrate/20260915210000_add_slowest_index_to_queries.rb +18 -0
  149. data/db/railwatch_telemetry_migrate/20260917010000_add_batch_ledger_to_ingest_batches.rb +16 -0
  150. data/docs/configuration.md +26 -0
  151. data/docs/embedded.md +352 -0
  152. data/docs/getting-started.md +5 -0
  153. data/lib/generators/railwatch/install/install_generator.rb +222 -1
  154. data/lib/generators/railwatch/install/templates/{initializer.rb → initializer.rb.tt} +39 -0
  155. data/lib/generators/railwatch/install/templates/post-deploy +8 -0
  156. data/lib/puma/plugin/railwatch.rb +170 -0
  157. data/lib/railwatch/authentication.rb +83 -0
  158. data/lib/railwatch/configuration.rb +134 -3
  159. data/lib/railwatch/dashboard_assets.rb +45 -0
  160. data/lib/railwatch/embedded.rb +54 -0
  161. data/lib/railwatch/engine.rb +89 -1
  162. data/lib/railwatch/ingest_request_body_limit.rb +10 -0
  163. data/lib/railwatch/json_compat.rb +60 -0
  164. data/lib/railwatch/maintenance.rb +183 -0
  165. data/lib/railwatch/patches/runner_command.rb +21 -1
  166. data/lib/railwatch/record.rb +33 -8
  167. data/lib/railwatch/reporter.rb +16 -0
  168. data/lib/railwatch/subscribers/process_info.rb +2 -1
  169. data/lib/railwatch/transport/local.rb +78 -0
  170. data/lib/railwatch/transport/socket.rb +183 -0
  171. data/lib/railwatch/version.rb +1 -1
  172. data/lib/railwatch/writer.rb +370 -0
  173. data/lib/railwatch.rb +38 -1
  174. data/lib/tasks/railwatch_tasks.rake +128 -0
  175. data/public/railwatch/assets/CommitMono-Bold-D6h61ieg.woff2 +0 -0
  176. data/public/railwatch/assets/CommitMono-Regular-zr8w7Obm.woff2 +0 -0
  177. data/public/railwatch/assets/Roboto-Black-auA4GeOK.woff2 +0 -0
  178. data/public/railwatch/assets/Roboto-Bold-CJLnO8j1.woff2 +0 -0
  179. data/public/railwatch/assets/Roboto-Medium-Cm2bwKpj.woff2 +0 -0
  180. data/public/railwatch/assets/Roboto-Regular-Chaq1-PV.woff2 +0 -0
  181. data/public/railwatch/assets/app-layout-yh-sPWgK.js +1 -0
  182. data/public/railwatch/assets/app-wordmark-nbjkzxwQ.js +1 -0
  183. data/public/railwatch/assets/appearance-CDuRvQTB.js +1 -0
  184. data/public/railwatch/assets/application-C_kpBdnf.css +1 -0
  185. data/public/railwatch/assets/arrow-up-DVtOGdVA.js +1 -0
  186. data/public/railwatch/assets/auth-layout-TCwPpS1K.js +1 -0
  187. data/public/railwatch/assets/badge-Daw4Hvr8.js +1 -0
  188. data/public/railwatch/assets/braces-DhFbHPsz.js +1 -0
  189. data/public/railwatch/assets/card-BX_3HXcJ.js +1 -0
  190. data/public/railwatch/assets/chart-B14-N9g7.js +39 -0
  191. data/public/railwatch/assets/chart-hover-Vy52H4uD.js +1 -0
  192. data/public/railwatch/assets/chart-panel-Cb4S_ej_.js +1 -0
  193. data/public/railwatch/assets/checkbox-D3aRSsBj.js +1 -0
  194. data/public/railwatch/assets/code-KvW8k7Jr.js +1 -0
  195. data/public/railwatch/assets/copy-DaQMJWoT.js +1 -0
  196. data/public/railwatch/assets/copy-block-BKFGd11J.js +1 -0
  197. data/public/railwatch/assets/copy-id-Djks1fXB.js +1 -0
  198. data/public/railwatch/assets/cursor-load-more-BXV0f1D_.js +1 -0
  199. data/public/railwatch/assets/data-table-iv1bdF6u.js +1 -0
  200. data/public/railwatch/assets/edit-BZc_Iawe.js +1 -0
  201. data/public/railwatch/assets/edit-DMKUF8Zi.js +8 -0
  202. data/public/railwatch/assets/edit-__9yJlO3.js +1 -0
  203. data/public/railwatch/assets/empty-state-6j_0AaQQ.js +1 -0
  204. data/public/railwatch/assets/env-layout-DrT8rO6P.js +1 -0
  205. data/public/railwatch/assets/execution-path-CzgBUi5e.js +1 -0
  206. data/public/railwatch/assets/filter-bar-9SU5NrzX.js +1 -0
  207. data/public/railwatch/assets/flamegraph-qcekju8V.js +2 -0
  208. data/public/railwatch/assets/format-B9SDkrWj.js +1 -0
  209. data/public/railwatch/assets/frames-Cyu7KMxZ.js +1 -0
  210. data/public/railwatch/assets/google-sign-in-button-DsTSfmzY.js +1 -0
  211. data/public/railwatch/assets/index-1ol1-QWI.js +1 -0
  212. data/public/railwatch/assets/index-5jI4aFzC.js +1 -0
  213. data/public/railwatch/assets/index-9KTrVnrc.js +1 -0
  214. data/public/railwatch/assets/index-B0-8lcTp.js +1 -0
  215. data/public/railwatch/assets/index-B7jjfNfO.js +1 -0
  216. data/public/railwatch/assets/index-BBchRy0M.js +1 -0
  217. data/public/railwatch/assets/index-BRiq3SNR.js +1 -0
  218. data/public/railwatch/assets/index-BgKj9xhr.js +1 -0
  219. data/public/railwatch/assets/index-BkTZqqOu.js +1 -0
  220. data/public/railwatch/assets/index-BprKx8QO.js +1 -0
  221. data/public/railwatch/assets/index-C3jzvPs3.js +1 -0
  222. data/public/railwatch/assets/index-Cbs6gGyQ.js +1 -0
  223. data/public/railwatch/assets/index-CdRZ6AWF.js +1 -0
  224. data/public/railwatch/assets/index-CeYKnapu.js +1 -0
  225. data/public/railwatch/assets/index-Cmlwy1-V.js +1 -0
  226. data/public/railwatch/assets/index-Cwx6058d.js +1 -0
  227. data/public/railwatch/assets/index-D4CSdbHv.js +1 -0
  228. data/public/railwatch/assets/index-DEFMSkdG.js +1 -0
  229. data/public/railwatch/assets/index-DFiHEBSh.js +1 -0
  230. data/public/railwatch/assets/index-DL4vWdWJ.js +1 -0
  231. data/public/railwatch/assets/index-DU9F5b5d.js +1 -0
  232. data/public/railwatch/assets/index-DaXgPcGL.js +1 -0
  233. data/public/railwatch/assets/index-DbtaU-EE.js +1 -0
  234. data/public/railwatch/assets/index-DeOe83F4.js +1 -0
  235. data/public/railwatch/assets/index-DiucHN4B.js +1 -0
  236. data/public/railwatch/assets/index-DlnR_l9o.js +1 -0
  237. data/public/railwatch/assets/index-DlumCsWY.js +2 -0
  238. data/public/railwatch/assets/index-DmRd7aIG.js +1 -0
  239. data/public/railwatch/assets/index-DxSh2UpM.js +1 -0
  240. data/public/railwatch/assets/index-MIMGuFNt.js +1 -0
  241. data/public/railwatch/assets/index-OqI59zPb.js +1 -0
  242. data/public/railwatch/assets/index-P4rC7IlX.js +1 -0
  243. data/public/railwatch/assets/index-gpPOcFWq.js +1 -0
  244. data/public/railwatch/assets/index-oVkururr.js +1 -0
  245. data/public/railwatch/assets/index-p9puqVge.js +1 -0
  246. data/public/railwatch/assets/inertia-TViv6kNv.js +97 -0
  247. data/public/railwatch/assets/input-error-LxImUkxv.js +1 -0
  248. data/public/railwatch/assets/json-viewer-Ar4cjPDW.js +1 -0
  249. data/public/railwatch/assets/klass-CJ-J4INB.js +1 -0
  250. data/public/railwatch/assets/label-COUKWqE_.js +1 -0
  251. data/public/railwatch/assets/layout-DNSLAkw_.js +1 -0
  252. data/public/railwatch/assets/live-dot-ChfUtY3p.js +41 -0
  253. data/public/railwatch/assets/nav-CNnDqPlm.js +1 -0
  254. data/public/railwatch/assets/new-84S8ZJq9.js +1 -0
  255. data/public/railwatch/assets/new-Be55nmt9.js +1 -0
  256. data/public/railwatch/assets/new-Bi_xQiIb.js +1 -0
  257. data/public/railwatch/assets/new-BvCT8TMg.js +1 -0
  258. data/public/railwatch/assets/new-D05SajFR.js +1 -0
  259. data/public/railwatch/assets/new-D4uewYC8.js +1 -0
  260. data/public/railwatch/assets/onboarding-CYZi5Cqc.js +1 -0
  261. data/public/railwatch/assets/origin-identity-6q1-CBts.js +1 -0
  262. data/public/railwatch/assets/percentile-picker-gFZCXtdb.js +1 -0
  263. data/public/railwatch/assets/relative-time-IOOgl5n2.js +1 -0
  264. data/public/railwatch/assets/release-health-DC8oc7uw.js +1 -0
  265. data/public/railwatch/assets/route-Dv6LAWvT.js +1 -0
  266. data/public/railwatch/assets/segmented-h1VdDTqE.js +1 -0
  267. data/public/railwatch/assets/select-_AJsUa7X.js +1 -0
  268. data/public/railwatch/assets/separator-BwwTYtCF.js +1 -0
  269. data/public/railwatch/assets/series-chart-DaFPefku.js +1 -0
  270. data/public/railwatch/assets/show-B7NCgkEo.js +1 -0
  271. data/public/railwatch/assets/show-BKqyKjBK.js +1 -0
  272. data/public/railwatch/assets/show-BM6X2Mpo.js +1 -0
  273. data/public/railwatch/assets/show-BNw4tN5q.js +1 -0
  274. data/public/railwatch/assets/show-BO3bnG5h.js +1 -0
  275. data/public/railwatch/assets/show-BhrAVAEA.js +1 -0
  276. data/public/railwatch/assets/show-C4Ltf5i9.js +2 -0
  277. data/public/railwatch/assets/show-C8sHalnw.js +1 -0
  278. data/public/railwatch/assets/show-CeTL4B37.js +2 -0
  279. data/public/railwatch/assets/show-CpfgV1jP.js +1 -0
  280. data/public/railwatch/assets/show-DACku6AD.js +3 -0
  281. data/public/railwatch/assets/show-DIOSGcXV.js +6 -0
  282. data/public/railwatch/assets/show-DQp_1n-B.js +1 -0
  283. data/public/railwatch/assets/show-DVNz46RI.js +1 -0
  284. data/public/railwatch/assets/show-DYteoYWW.js +1 -0
  285. data/public/railwatch/assets/show-DgSIoRvA.js +1 -0
  286. data/public/railwatch/assets/show-JxFtB4eK.js +2 -0
  287. data/public/railwatch/assets/sort-header-DpFzXblu.js +1 -0
  288. data/public/railwatch/assets/source-link-B2183i2-.js +1 -0
  289. data/public/railwatch/assets/sparkline-cell-C3-5vFkP.js +1 -0
  290. data/public/railwatch/assets/stat-s4RpOS9w.js +1 -0
  291. data/public/railwatch/assets/status-badge-8jVV-LA4.js +1 -0
  292. data/public/railwatch/assets/tenant-path-G-6u9A-o.js +1 -0
  293. data/public/railwatch/assets/text-link-DfsiaCcP.js +1 -0
  294. data/public/railwatch/assets/textarea-Dye72uP7.js +1 -0
  295. data/public/railwatch/assets/timeline-CD7WHnbo.js +1 -0
  296. data/public/railwatch/assets/transition-B_AW8rMK.js +5 -0
  297. data/public/railwatch/assets/use-clipboard-ColgLyQ2.js +1 -0
  298. data/public/railwatch/icon.png +0 -0
  299. data/public/railwatch/icon.svg +5 -0
  300. data/public/railwatch/manifest.json +2171 -0
  301. data/public/railwatch/rails-vite.json +1 -0
  302. metadata +314 -4
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ # Base for every dashboard page: the prebuilt Inertia bundle, the shared
5
+ # props its layouts read, and per-controller Inertia config so a host that
6
+ # also uses Inertia keeps its own.
7
+ #
8
+ # Inherits from Railwatch.config.base_controller_class (default
9
+ # ActionController::Base) so a host can put its own admin gate in front of
10
+ # every page the way Mission Control Jobs allows: point it at a controller
11
+ # whose before_action requires an admin, and turn HTTP Basic off.
12
+ class DashboardController < Railwatch.config.base_controller_class.constantize
13
+ include Railwatch::AssetsHelper
14
+ helper Railwatch::AssetsHelper
15
+ include Railwatch::EnvironmentScoped
16
+
17
+ layout "railwatch/dashboard"
18
+ # The bundle's pages are named after the platform's controllers
19
+ # (overview/show, requests/index); strip the engine namespace so
20
+ # default_render finds them.
21
+ inertia_config version: -> { Railwatch::AssetsHelper.digest }, layout: "railwatch/dashboard",
22
+ use_script_element_for_initial_page: true, always_include_errors_hash: true, default_render: true,
23
+ component_path_resolver: ->(path:, action:) { "#{path.delete_prefix('railwatch/')}/#{action}" }
24
+ rescue_from Telemetry::CursorPage::InvalidCursor do |exception|
25
+ render plain: exception.message, status: :unprocessable_content
26
+ end
27
+
28
+ # HTTP Basic, on and closed by default (Configuration#http_basic_auth_*).
29
+ # Runs before anything reads telemetry; a host that authenticates in its
30
+ # base controller or a routes constraint turns it off.
31
+ before_action :authenticate_by_http_basic
32
+ before_action { Viewer.user = Railwatch.config.resolve_dashboard_user(request) }
33
+
34
+ inertia_share auth: -> { { user: Viewer.user.as_json, session: { id: "embedded", recently_authenticated: true } } },
35
+ account: -> { Railwatch::Embedded::Account.as_json },
36
+ accounts: -> { [ { id: 1, name: Railwatch::Embedded::Account.name } ] },
37
+ applications: -> { [ { id: 1, name: Application.current.name, slug: Application.current.slug,
38
+ issue_prefix: Application.current.issue_prefix,
39
+ environments: [ { id: 1, name: Environment.current.name, slug: Environment.current.slug,
40
+ last_seen_at: Environment.current.last_seen_at, paused: false } ] } ] },
41
+ flash: -> { { alert: flash.alert, warning: flash[:warning], notice: flash.notice } },
42
+ google_oauth: false,
43
+ embedded: true
44
+
45
+ private
46
+
47
+ def authenticate_by_http_basic
48
+ config = Railwatch.config
49
+ return unless config.http_basic_auth_enabled
50
+
51
+ if config.http_basic_auth_configured?
52
+ http_basic_authenticate_or_request_with(name: config.http_basic_auth_user, password: config.http_basic_auth_password,
53
+ realm: "Railwatch")
54
+ else
55
+ # Closed, not open: same as Mission Control with no credentials. The
56
+ # challenge header is still sent so a client or a monitor sees the
57
+ # scheme, and the body says what to do, since a bare 401 looks like a
58
+ # broken install rather than an unconfigured one.
59
+ response.set_header("WWW-Authenticate", %(Basic realm="Railwatch"))
60
+ render plain: "Railwatch: HTTP Basic authentication is on and no credentials are configured, so the " \
61
+ "dashboard is closed. Run `bin/rails railwatch:authentication:configure` (writes " \
62
+ "railwatch.http_basic_auth_user/_password to Rails credentials), or set " \
63
+ "RAILWATCH_HTTP_BASIC_AUTH_USER and _PASSWORD, or turn Basic off " \
64
+ "(c.http_basic_auth_enabled = false) once your own auth gates the mount. See docs/embedded.md.",
65
+ status: :unauthorized
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ class DeploysController < DashboardController
5
+ def index
6
+ render inertia: { deploys: environment.deploys.recent.limit(100).map { |d| deploy_row(d) } }
7
+ end
8
+
9
+ # Before/after comparison: the hour before the deploy versus the hour after.
10
+ def show
11
+ deploy = environment.deploys.find(params[:id])
12
+ before = [ deploy.deployed_at - 1.hour, deploy.deployed_at ]
13
+ after = [ deploy.deployed_at, deploy.deployed_at + 1.hour ]
14
+ compare, health = telemetry do
15
+ [ %w[request job_attempt].to_h do |type|
16
+ [ type, { before: Telemetry::Rollup.summarize(Telemetry::Rollup.for_type(type).where(bucket: before.first.beginning_of_hour..before.last)),
17
+ after: Telemetry::Rollup.summarize(Telemetry::Rollup.for_type(type).where(bucket: after.first.beginning_of_hour..after.last)) } ]
18
+ end, Telemetry::ReleaseHealth.for_deploy(deploy.deploy, *deploy.window) ]
19
+ end
20
+ new_issues = environment.issues.where(first_seen_at: after.first..after.last + 23.hours).recent.map { |i| { id: i.id, key: i.key, title: i.title, status: i.status } }
21
+ resolved = environment.issues.where(resolved_in_deploy: deploy.deploy).map { |i| { id: i.id, key: i.key, title: i.title } }
22
+ render inertia: { deploy: deploy_row(deploy).merge(commits: deploy.commits, previous_ref: deploy.previous_ref,
23
+ repository_ref: deploy.ref, detail: deploy.detail),
24
+ compare: compare, health: health, new_issues: new_issues, resolved_issues: resolved }
25
+ end
26
+
27
+ private
28
+
29
+ def deploy_row(d)
30
+ { id: d.id, deploy: d.deploy, ref: d.short_ref, name: d.name, url: d.url, server: d.server, deployed_at: d.deployed_at,
31
+ commits_count: d.commits_count, performer: d.detail["performer"] }
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ class DeprecationsController < DashboardController
5
+ def index
6
+ from, to = window_range
7
+ parsed = FilterQuery.parse(params[:q])
8
+ fields = parsed[:fields]
9
+ data = telemetry do
10
+ scope = Telemetry::Deprecation.between(from, to)
11
+ scope = scope.where(gem_name: fields["gem"]) if fields["gem"].present?
12
+ scope = scope.where("message LIKE ?", "%#{Telemetry::Deprecation.sanitize_sql_like(parsed[:text])}%") if parsed[:text].present?
13
+ rows = scope.to_a
14
+ hashes = rows.map(&:group_hash).uniq
15
+ first_ever = Telemetry::Deprecation.where(group_hash: hashes).group(:group_hash).minimum(:occurred_at)
16
+ groups = rows.group_by(&:group_hash)
17
+ .map { |group_hash, group_rows| deprecation_row(group_hash, group_rows, first_ever[group_hash], from, to) }
18
+ .sort_by { |r| -r[:count] }
19
+ { groups: groups.first(200), distinct: groups.size, total_occurrences: groups.sum { |g| g[:count] },
20
+ new_this_window: groups.count { |g| g[:first_seen_at] && g[:first_seen_at] >= from } }
21
+ end
22
+ render inertia: { deprecations: data[:groups], distinct: data[:distinct], total_occurrences: data[:total_occurrences],
23
+ new_this_window: data[:new_this_window], q: params[:q].to_s }
24
+ end
25
+
26
+ private
27
+
28
+ def deprecation_row(group_hash, group_rows, first_seen_at, from, to)
29
+ sorted = group_rows.sort_by(&:occurred_at)
30
+ { group_hash: group_hash, message: sorted.last.message, gem_name: sorted.last.gem_name, horizon: sorted.last.horizon,
31
+ source: sorted.last.source, count: group_rows.size, first_seen_at: first_seen_at, last_seen_at: sorted.last.occurred_at,
32
+ sparkline: hourly_sparkline(group_rows, from, to), occurrences: sorted.reverse.first(20).map { |r| occurrence_row(r) } }
33
+ end
34
+
35
+ def occurrence_row(r)
36
+ { id: r.id, occurred_at: r.occurred_at, execution_id: r.execution_id, execution_source: r.execution_source,
37
+ execution_preview: r.execution_preview }
38
+ end
39
+
40
+ # Hourly counts for raw (non-rollup) rows, zero-filled and coarsened to at
41
+ # most 30 points for wide windows -- deprecations have no duration column
42
+ # so they never land in Telemetry::Rollup and can't use
43
+ # EnvironmentScoped#sparkline, which keys off a rollup row's bucket/count.
44
+ def hourly_sparkline(rows, from, to)
45
+ hours = [ ((to - from) / 1.hour).ceil, 1 ].max
46
+ coarsen = (hours / 30.0).ceil
47
+ base = from.beginning_of_hour
48
+ buckets = Hash.new(0)
49
+ rows.each { |r| buckets[((r.occurred_at - base) / 1.hour).to_i / coarsen] += 1 }
50
+ bucket_count = (hours.to_f / coarsen).ceil
51
+ (0...bucket_count).map { |i| buckets[i] }
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ # The platform's EnvironmentScoped concern with the account lookup replaced
5
+ # by the embedded singleton. Everything below the seam is the platform's code.
6
+ module EnvironmentScoped
7
+ extend ActiveSupport::Concern
8
+
9
+ WINDOWS = { "1h" => 1.hour, "6h" => 6.hours, "24h" => 24.hours, "7d" => 7.days, "30d" => 30.days }.freeze
10
+ MAX_CUSTOM_RANGE = 90.days
11
+
12
+ included do
13
+ before_action :set_environment
14
+ inertia_share environment: -> { environment_props }
15
+ inertia_share window: -> { window_key }
16
+ inertia_share range: -> { from, to = window_range; { from: from.iso8601(6), to: to.iso8601(6) } }
17
+ inertia_share saved_views: -> { SavedView.props_for(environment, Viewer.user) }
18
+ end
19
+
20
+ private
21
+
22
+ attr_reader :environment
23
+
24
+ def set_environment
25
+ @environment = Environment.find(params[:environment_id] || Environment::ID)
26
+ end
27
+
28
+ def window_key
29
+ custom_range ? "custom" : (WINDOWS.key?(params[:window].to_s) ? params[:window].to_s : "24h")
30
+ end
31
+
32
+ def window_range
33
+ return @window_range if defined?(@window_range)
34
+ @window_range = custom_range || begin
35
+ key = WINDOWS.key?(params[:window].to_s) ? params[:window].to_s : "24h"
36
+ to = Time.current
37
+ [ to - WINDOWS[key], to ]
38
+ end
39
+ end
40
+
41
+ def custom_range
42
+ return @custom_range if defined?(@custom_range)
43
+ from = parse_time(params[:from])
44
+ to = from && (parse_time(params[:to]) || Time.current)
45
+ valid = from && to && to > from && (to - from) <= MAX_CUSTOM_RANGE
46
+ @custom_range = valid ? [ from, to ] : nil
47
+ end
48
+
49
+ def parse_time(value)
50
+ return nil if value.blank?
51
+ Time.zone.parse(value.to_s)
52
+ rescue ArgumentError, TypeError
53
+ nil
54
+ end
55
+
56
+ def previous_window_range
57
+ from, to = window_range
58
+ span = to - from
59
+ [ from - span, from ]
60
+ end
61
+
62
+ def telemetry(&block) = environment.with_telemetry(&block)
63
+
64
+ def telemetry_cursor_context(resource)
65
+ from, to = window_range
66
+ FilterQuery.cursor_context(environment_id: environment.id, resource: resource, query: params[:q], window: [ from.iso8601(6), to.iso8601(6) ])
67
+ end
68
+
69
+ def application = Application.current
70
+
71
+ def environment_props
72
+ { id: environment.id, name: environment.name, slug: environment.slug, application_id: application.id,
73
+ application_name: application.name, issue_prefix: application.issue_prefix, last_seen_at: environment.last_seen_at,
74
+ paused: false, token_prefix: environment.token_prefix,
75
+ repository_url: application.repository_url, default_branch: application.default_branch }
76
+ end
77
+
78
+ def deploys_in_window
79
+ environment.deploys.between(*window_range).recent.limit(50).map { |d| { deploy: d.deploy, ref: d.short_ref, at: d.deployed_at } }
80
+ end
81
+
82
+ def series(record_type, group_hash: nil, name: nil)
83
+ from, to = window_range
84
+ Telemetry::Aggregations.series(environment, record_type, from: from, to: to, group_hash: group_hash, name: name)
85
+ end
86
+
87
+ def grouped(record_type, limit: 100, order: nil, dir: nil)
88
+ from, to = window_range
89
+ Telemetry::Aggregations.grouped(environment, record_type, from: from, to: to, limit: limit, order: order, dir: dir)
90
+ end
91
+
92
+ def summary_with_delta(record_type, group_hash: nil)
93
+ from, to = window_range
94
+ previous_from, previous_to = previous_window_range
95
+ Telemetry::Aggregations.summary_with_delta(environment, record_type, from: from, to: to,
96
+ previous_from: previous_from, previous_to: previous_to, group_hash: group_hash)
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ class ExceptionsController < DashboardController
5
+ def index
6
+ from, to = window_range
7
+ page_data = nil
8
+ page = lambda do
9
+ page_data ||= telemetry do
10
+ scope = FilterQuery.apply(Telemetry::Exception.all, resource: :exceptions, query: params[:q], from: from, to: to)
11
+ recent, pagination = Telemetry::CursorPage.call(scope, cursor: params[:cursor], limit: params[:limit],
12
+ context: telemetry_cursor_context(:exceptions))
13
+ issues_by_group = environment.issues.where(group_hash: recent.map(&:group_hash).uniq).index_by(&:group_hash)
14
+ [ recent.map { |e| exception_row(e, issues_by_group) }, pagination ]
15
+ end
16
+ end
17
+ render inertia: {
18
+ exceptions: InertiaRails.merge { page.call.first }, pagination: -> { page.call.last },
19
+ classes: -> { telemetry { Telemetry::Exception.between(from, to).group(:class_name).count.sort_by { |_class, n| -n }.first(15) } },
20
+ series: -> { telemetry { exception_series(from, to) } },
21
+ total: -> { telemetry { Telemetry::Exception.between(from, to).count } },
22
+ unhandled: -> { telemetry { Telemetry::Exception.between(from, to).unhandled.count } }, q: params[:q].to_s
23
+ }
24
+ end
25
+
26
+ private
27
+
28
+ def exception_row(e, issues_by_group)
29
+ issue = issues_by_group[e.group_hash]
30
+ { id: e.id, class_name: e.class_name, message: e.message.first(500), handled: e.handled, severity: e.severity, source: e.source,
31
+ file: e.file, line: e.line, occurred_at: e.occurred_at, execution_id: e.execution_id, execution_source: e.execution_source,
32
+ execution_preview: e.execution_preview, user_ref: e.user_ref, group_hash: e.group_hash, issue_id: issue&.id, issue_key: issue&.key }
33
+ end
34
+
35
+ # Telemetry::Rollup has no "exception" record type (exceptions carry no
36
+ # duration to absorb), so the chart is a plain bucketed count over the raw
37
+ # table instead of the usual EnvironmentScoped#series.
38
+ # `errors` carries the unhandled count so the chart legend (handled /
39
+ # unhandled) agrees with the Unhandled card above the table.
40
+ def exception_series(from, to)
41
+ buckets = Hash.new { |h, k| h[k] = { count: 0, errors: 0 } }
42
+ Telemetry::Exception.between(from, to)
43
+ .group(Arel.sql("strftime('%Y-%m-%dT%H:00:00Z', occurred_at)"), :handled)
44
+ .count
45
+ .each do |(bucket, handled), c|
46
+ buckets[bucket][:count] += c
47
+ buckets[bucket][:errors] += c unless handled
48
+ end
49
+ buckets.map { |bucket, b| { t: bucket, count: b[:count], errors: b[:errors], client_errors: 0, avg: 0, p50: 0, p95: 0, p99: 0 } }
50
+ .sort_by { |r| r[:t] }
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Generic detail route for execution kinds that do not have a dedicated index
4
+ # yet (for example Action Cable channel actions), and for legacy child records
5
+ # whose execution_source was absent.
6
+ module Railwatch
7
+ class ExecutionsController < DashboardController
8
+ def show
9
+ execution = telemetry { Telemetry::Execution.find_by!(execution_id: params[:id]) }
10
+ render inertia: "executions/show", props: ExecutionPresenter.new(execution, environment).props
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,258 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ class IssuesController < DashboardController
5
+ BULK_LIMIT = 200
6
+
7
+ before_action :set_issue, only: %i[show update merge_candidates]
8
+ rescue_from Issue::InvalidMerge, with: :invalid_merge
9
+
10
+ def index
11
+ scope = Issue.all
12
+ scope = scope.where(status: params[:status]) if Issue::STATUSES.include?(params[:status].to_s)
13
+ scope = scope.where(status: "open") unless params.key?(:status)
14
+ scope = scope.where(application_id: params[:application_id]) if params[:application_id].present?
15
+ scope = scope.where(environment_id: params[:environment_id]) if params[:environment_id].present?
16
+ scope = scope.where(kind: params[:kind]) if Issue::KINDS.include?(params[:kind].to_s)
17
+ scope = scope.where(assignee_id: Viewer.user.id) if params[:mine] == "1"
18
+ # Same "key:value free text" grammar as the other list pages: today the
19
+ # only key is source, which is how you pull out the browser's own errors.
20
+ parsed = FilterQuery.parse(params[:q])
21
+ scope = scope.where(source: parsed[:fields]["source"]) if parsed[:fields]["source"].present?
22
+ scope = scope.where("title LIKE ?", "%#{Issue.sanitize_sql_like(parsed[:text])}%") if parsed[:text].present?
23
+ render inertia: { issues: scope.recent.limit(200).map { |i| row(i) },
24
+ filters: params.permit(:status, :application_id, :environment_id, :kind, :mine, :q).to_h,
25
+ counts: Issue.all.group(:status).count,
26
+ kindCounts: Issue.all.open.group(:kind).count,
27
+ members: User.default.then { |u| [ { id: u.id, name: u.name } ] } }
28
+ end
29
+
30
+ def show
31
+ env = @issue.environment
32
+ return render_detection_issue(env) unless @issue.kind == "exception"
33
+
34
+ from = 30.days.ago
35
+ occurrences = env.with_telemetry do
36
+ Telemetry::Exception.where(group_hash: @issue.group_hash).between(from, Time.current).recent.limit(50)
37
+ .map { |e| { id: e.id, message: e.message.first(300), handled: e.handled, occurred_at: e.occurred_at, execution_id: e.execution_id, execution_source: e.execution_source, execution_preview: e.execution_preview, user_ref: e.user_ref, tenant: e.app_tenant, deploy: e.deploy, server: e.server } }
38
+ end
39
+ latest = env.with_telemetry { Telemetry::Exception.where(group_hash: @issue.group_hash).recent.first }
40
+ browser_breadcrumbs = browser_breadcrumbs_for(latest)
41
+ breadcrumbs = latest&.execution_id ? env.with_telemetry { breadcrumbs_for(latest) } : []
42
+ daily_counts = env.with_telemetry { Telemetry::Exception.where(group_hash: @issue.group_hash).between(from, Time.current).group(Arel.sql("date(occurred_at)")).count }.transform_keys(&:to_s)
43
+ daily = (from.to_date..Date.current).map { |d| { day: d.to_s, count: daily_counts[d.to_s].to_i } }
44
+ by_deploy = env.with_telemetry { Telemetry::Exception.where(group_hash: @issue.group_hash).between(from, Time.current).group(:deploy).count }
45
+ by_tenant = env.with_telemetry { Telemetry::Exception.where(group_hash: @issue.group_hash).between(from, Time.current).where.not(app_tenant: nil).group(:app_tenant).count }
46
+ attachments = env.with_telemetry { attachments_for_issue }
47
+ # What a split by message would produce, and what the fingerprint that
48
+ # grouped these occurrences was -- both drive the Grouping panel.
49
+ messages = env.with_telemetry { Telemetry::Exception.where(group_hash: @issue.group_hash).recent.limit(Issue::SPLIT_SAMPLE).pluck(:message) }.tally
50
+ render inertia: {
51
+ issue: row(@issue).merge(first_seen_at: @issue.first_seen_at, resolved_at: @issue.resolved_at, resolved_in_deploy: @issue.resolved_in_deploy, regressed_at: @issue.regressed_at, sample: @issue.sample),
52
+ detection: nil,
53
+ latest: latest && { id: latest.id, class_name: latest.class_name, message: latest.message, handled: latest.handled, severity: latest.severity, source: latest.source, file: latest.file, line: latest.line, frames: latest.frames, cause: latest.cause, locals: latest.locals, context: context_without_breadcrumbs(latest), ruby_version: latest.ruby_version, rails_version: latest.rails_version, execution_id: latest.execution_id, execution_source: latest.execution_source, execution_preview: latest.execution_preview, deploy: latest.deploy },
54
+ breadcrumbs: breadcrumbs, browser_breadcrumbs: browser_breadcrumbs,
55
+ fingerprint: (latest&.fingerprint).presence || Array(@issue.sample["fingerprint"]),
56
+ fingerprint_source: latest&.fingerprint_source || @issue.sample["fingerprint_source"],
57
+ distinct_messages: messages.size,
58
+ top_messages: messages.sort_by { |_, n| -n }.first(5).map { |message, n| { message: message.to_s.first(200), count: n } },
59
+ occurrences: occurrences, daily: daily, by_deploy: by_deploy, by_tenant: by_tenant, attachments: attachments,
60
+ comments: @issue.comments.order(:created_at).map { |c| { id: c.id, body: c.body, user: c.user&.name || c.author_name || "Linear", source: c.source, created_at: c.created_at } },
61
+ activities: @issue.activities.order(:created_at).map { |a| activity_row(a) },
62
+ members: User.default.then { |u| [ { id: u.id, name: u.name } ] },
63
+ deploys: env.deploys.recent.limit(20).map { |d| { deploy: d.deploy, ref: d.short_ref, at: d.deployed_at } },
64
+ related_issues: related_issues,
65
+ merged_into: @issue.merged_into && { id: @issue.merged_into.id, key: @issue.merged_into.key, title: @issue.merged_into.title },
66
+ merged_issues: @issue.merged_issues.map { |i| { id: i.id, key: i.key, title: i.title } },
67
+ alerts: @issue.alerts.order(created_at: :desc).map { |a| alert_row(a) }
68
+ }
69
+ end
70
+
71
+ def merge_candidates
72
+ query = params[:q].to_s.strip.first(200)
73
+ scope = @issue.environment.issues.open.where.not(id: @issue.id)
74
+ if query.present?
75
+ pattern = "%#{Issue.sanitize_sql_like(query)}%"
76
+ key_match = query.upcase.match(/\A#{Regexp.escape(@issue.application.issue_prefix)}-(\d+)\z/)
77
+ scope = if key_match
78
+ scope.where("title LIKE :pattern OR number = :number", pattern: pattern, number: key_match[1].to_i)
79
+ else
80
+ scope.where("title LIKE ?", pattern)
81
+ end
82
+ end
83
+
84
+ render json: { issues: scope.order(last_seen_at: :desc, id: :desc).limit(20)
85
+ .map { |issue| { id: issue.id, key: issue.key, title: issue.title } } }
86
+ end
87
+
88
+ def update
89
+ notice = "Issue updated"
90
+ case params[:action_name]
91
+ when "resolve" then @issue.resolve!(deploy: params[:deploy].presence || @issue.environment.deploys.recent.first&.deploy)
92
+ when "ignore" then @issue.ignore!
93
+ when "reopen" then @issue.reopen!
94
+ when "assign" then @issue.update!(assignee: assignee_from(params[:assignee_id]))
95
+ when "assign_me" then @issue.update!(assignee_id: Viewer.user.id)
96
+ when "priority" then @issue.update!(priority: params[:priority])
97
+ when "merge" then @issue.merge_into!(@issue.environment.issues.open.find(params[:target_id]))
98
+ when "unmerge" then @issue.unmerge!
99
+ when "split" then notice = split_notice(@issue.split!(by: params[:by].to_s))
100
+ else @issue.update!(params.permit(:title))
101
+ end
102
+ redirect_to issue_path(@issue), notice: notice
103
+ end
104
+
105
+ def bulk
106
+ ids = Array(params[:ids]).first(BULK_LIMIT)
107
+ scope = Issue.all.where(id: ids)
108
+ if params[:action_name] == "merge"
109
+ bulk_merge(scope)
110
+ else
111
+ scope.find_each do |issue|
112
+ case params[:action_name]
113
+ when "resolve" then issue.resolve!(deploy: issue.environment.deploys.recent.first&.deploy)
114
+ when "ignore" then issue.ignore!
115
+ when "priority" then issue.update!(priority: params[:value])
116
+ end
117
+ end
118
+ end
119
+ redirect_back fallback_location: issues_path, notice: "#{ids.size} issue(s) updated"
120
+ end
121
+
122
+ private
123
+
124
+ # A stale selection can contain issues that have already been merged since
125
+ # the list rendered. Ignore those sources, choose an open target, and wrap
126
+ # the whole operation so a concurrent state change cannot partially fold a
127
+ # bulk selection before the model rejects it.
128
+ def bulk_merge(scope)
129
+ issues = scope.order(:first_seen_at, :id).to_a
130
+ target = issues.find(&:open?)
131
+ return unless target
132
+
133
+ Issue.transaction do
134
+ issues.each do |issue|
135
+ next if issue.id == target.id || issue.environment_id != target.environment_id || issue.merged?
136
+
137
+ issue.merge_into!(target)
138
+ end
139
+ end
140
+ end
141
+
142
+ def invalid_merge(error)
143
+ redirect_back fallback_location: (@issue ? issue_path(@issue) : issues_path), alert: error.message
144
+ end
145
+
146
+ def render_detection_issue(env)
147
+ render inertia: {
148
+ issue: row(@issue).merge(first_seen_at: @issue.first_seen_at, resolved_at: @issue.resolved_at,
149
+ resolved_in_deploy: @issue.resolved_in_deploy, regressed_at: @issue.regressed_at, sample: @issue.sample),
150
+ detection: IssueDetectionPresenter.new(@issue).as_json,
151
+ latest: nil, breadcrumbs: [], browser_breadcrumbs: [], fingerprint: [], fingerprint_source: nil,
152
+ distinct_messages: 0, top_messages: [], occurrences: [], daily: [], by_deploy: {}, by_tenant: {}, attachments: [],
153
+ comments: @issue.comments.order(:created_at).map { |c| { id: c.id, body: c.body, user: c.user.name, created_at: c.created_at } },
154
+ activities: @issue.activities.order(:created_at).map { |a| activity_row(a) },
155
+ members: User.default.then { |u| [ { id: u.id, name: u.name } ] },
156
+ deploys: env.deploys.recent.limit(20).map { |d| { deploy: d.deploy, ref: d.short_ref, at: d.deployed_at } },
157
+ related_issues: related_issues,
158
+ merged_into: @issue.merged_into && { id: @issue.merged_into.id, key: @issue.merged_into.key, title: @issue.merged_into.title },
159
+ merged_issues: @issue.merged_issues.map { |i| { id: i.id, key: i.key, title: i.title } },
160
+ alerts: @issue.alerts.order(created_at: :desc).map { |a| alert_row(a) }
161
+ }
162
+ end
163
+
164
+ # Sentry-style breadcrumbs: everything the execution did before it raised
165
+ # (queries, cache, outgoing calls, logs, jobs), newest last, capped at 30.
166
+ def breadcrumbs_for(exception)
167
+ exe = Telemetry::Execution.find_by(execution_id: exception.execution_id) or return []
168
+ ExecutionPresenter.new(exe, @issue.environment).timeline_entries
169
+ .select { |e| e[:offset] <= ((exception.occurred_at - exe.occurred_at) * 1000.0) + 1 }
170
+ .reject { |e| e[:type] == "exception" && e[:id] == exception.id }
171
+ .last(30)
172
+ end
173
+
174
+ # A browser exception's execution is the beacon POST that carried it, whose
175
+ # timeline says nothing about the crash. What it has instead is the trail
176
+ # the browser client recorded -- console errors, clicks, navigations --
177
+ # which the gem stores in the exception's context.
178
+ def browser_breadcrumbs_for(exception)
179
+ trail = browser_context(exception)["breadcrumbs"]
180
+ return [] unless trail.is_a?(Array)
181
+ trail.filter_map { |crumb| crumb.slice("at", "kind", "text") if crumb.is_a?(Hash) }
182
+ end
183
+
184
+ # The same context with the trail taken back out, since the page renders it
185
+ # as a breadcrumb list rather than as raw JSON in the context panel.
186
+ def context_without_breadcrumbs(exception)
187
+ context = parsed_context(exception)
188
+ return exception.context unless context["browser"].is_a?(Hash) && context["browser"].key?("breadcrumbs")
189
+ context.merge("browser" => context["browser"].except("breadcrumbs")).to_json
190
+ end
191
+
192
+ def browser_context(exception)
193
+ context = parsed_context(exception)
194
+ context["browser"].is_a?(Hash) ? context["browser"] : {}
195
+ end
196
+
197
+ # Exception context is a JSON string the gem built; anything else in that
198
+ # column is an older or garbled record, and reads as empty rather than 500.
199
+ def parsed_context(exception)
200
+ parsed = JSON.parse(exception&.context.to_s)
201
+ parsed.is_a?(Hash) ? parsed : {}
202
+ rescue JSON::ParserError
203
+ {}
204
+ end
205
+
206
+ # Files the app attached while raising this exception, from any execution.
207
+ # `data` is left out of the select: it is the whole gzipped file and the
208
+ # page only links to the download.
209
+ def attachments_for_issue
210
+ Telemetry::Attachment.where(exception_group_hash: @issue.group_hash).recent.limit(20)
211
+ .select(:id, :name, :content_type, :bytes, :truncated, :occurred_at, :execution_id, :execution_preview)
212
+ .map { |a| { id: a.id, name: a.name, content_type: a.content_type, bytes: a.bytes, truncated: a.truncated,
213
+ viewable: a.viewable?, occurred_at: a.occurred_at, execution_id: a.execution_id, execution_preview: a.execution_preview } }
214
+ end
215
+
216
+ # split! no-ops when there is nothing to split apart (the button is hidden
217
+ # then, but a stale page can still ask), so say which one happened.
218
+ def split_notice(created)
219
+ created.empty? ? "Nothing to split \u2014 every recent occurrence is already in its own issue" : "Split into #{created.size} issue(s)"
220
+ end
221
+
222
+ def set_issue
223
+ @issue = Issue.all.find(params[:id])
224
+ end
225
+
226
+ # Only a member of the current account can be assigned; nil clears.
227
+ def assignee_from(id)
228
+ id.present? ? User.find(id) : nil
229
+ end
230
+
231
+ # Same exception class seen in other environments of the same application:
232
+ # issue titles are stored as "ClassName: message", so we match on the
233
+ # "ClassName:" prefix.
234
+ def related_issues
235
+ class_name = @issue.title.to_s.split(":", 2).first
236
+ return [] if class_name.blank?
237
+ Issue.all.where.not(id: @issue.id)
238
+ .where("title LIKE ?", "#{Issue.sanitize_sql_like(class_name)}:%").limit(20)
239
+ .map { |i| { id: i.id, key: i.key, title: i.title, status: i.status, environment: { id: i.environment.id, name: i.environment.name } } }
240
+ end
241
+
242
+ def row(i)
243
+ { id: i.id, key: i.key, title: i.title, kind: i.kind, status: i.status, priority: i.priority, culprit: i.culprit, occurrences: i.occurrences,
244
+ affected_users: i.affected_users, first_seen_at: i.first_seen_at, last_seen_at: i.last_seen_at, assignee: i.assignee && { id: i.assignee.id, name: i.assignee.name },
245
+ fingerprint_source: i.sample["fingerprint_source"], source: i.source,
246
+ application: { id: i.application.id, name: i.application.name }, environment: { id: i.environment.id, name: i.environment.name } }
247
+ end
248
+
249
+ def activity_row(a)
250
+ { id: a.id, kind: a.kind, data: a.data, user: a.user && { id: a.user.id, name: a.user.name }, created_at: a.created_at }
251
+ end
252
+
253
+ def alert_row(a)
254
+ { id: a.id, event: a.event, status: a.status, sent_at: a.sent_at, error: a.error,
255
+ integration: { kind: a.integration.kind, name: a.integration.name } }
256
+ end
257
+ end
258
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ class JobsController < DashboardController
5
+ include TelemetryIdentity
6
+
7
+ def index
8
+ from, to = window_range
9
+ page_data = nil
10
+ page = lambda do
11
+ page_data ||= telemetry do
12
+ scope = FilterQuery.apply(Telemetry::Execution.jobs, resource: :jobs, query: params[:q], from: from, to: to)
13
+ rows, meta = Telemetry::CursorPage.call(scope, cursor: params[:cursor], limit: params[:limit],
14
+ context: telemetry_cursor_context(:jobs))
15
+ people = origin_people(rows)
16
+ [ rows.map { |r| job_row(r, people) }, meta ]
17
+ end
18
+ end
19
+ render inertia: {
20
+ classes: -> { grouped("job_attempt", limit: 200) }, series: -> { series("job_attempt") },
21
+ queues: -> { queue_stats(from, to) }, recent: InertiaRails.merge { page.call.first },
22
+ pagination: -> { page.call.last }, deploys: -> { deploys_in_window }, q: params[:q].to_s
23
+ }
24
+ end
25
+
26
+ def klass
27
+ group_hash = params[:group_hash]
28
+ from, to = window_range
29
+ name, attempts = telemetry do
30
+ base = Telemetry::Execution.jobs.where(group_hash: group_hash)
31
+ rows = FilterQuery.apply(base, resource: :jobs,
32
+ query: params[:q], from: from, to: to).recent.limit(200).to_a
33
+ people = origin_people(rows)
34
+ [ base.between(from, to).pick(:name), rows.map { |row| job_row(row, people) } ]
35
+ end
36
+ render inertia: "jobs/klass", props: { name: name, group_hash: group_hash,
37
+ summary: summary_with_delta("job_attempt", group_hash: group_hash),
38
+ series: series("job_attempt", group_hash: group_hash), attempts: attempts,
39
+ deploys: deploys_in_window, q: params[:q].to_s }
40
+ end
41
+
42
+ def show
43
+ exe = telemetry { Telemetry::Execution.find_by!(execution_id: params[:id]) }
44
+ render inertia: "executions/show", props: ExecutionPresenter.new(exe, environment).props
45
+ end
46
+
47
+ private
48
+
49
+ def queue_stats(from, to)
50
+ telemetry do
51
+ Telemetry::Execution.jobs.between(from, to).group(:queue)
52
+ .pluck(:queue, Arel.sql("COUNT(*)"), Arel.sql("AVG(queue_latency)"), Arel.sql("SUM(CASE WHEN outcome = 'failed' THEN 1 ELSE 0 END)"))
53
+ .map { |q, c, lat, f| { queue: q, count: c, avg_latency: (lat.to_f / 1000.0).round(1), failed: f } }
54
+ end
55
+ end
56
+
57
+ def job_row(r, people)
58
+ { execution_id: r.execution_id, name: r.name, outcome: r.outcome, queue: r.queue, attempt: r.attempt, duration: r.duration_ms.round(2),
59
+ queue_latency: r.queue_latency && (r.queue_latency / 1000.0).round(1), occurred_at: r.occurred_at, exception_preview: r.exception_preview,
60
+ job_id: r.job_id, deploy: r.deploy }.merge(origin_identity(r, people))
61
+ end
62
+ end
63
+ end