railwatch 0.9.3 → 1.0.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 (454) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +37 -33
  3. data/CHANGELOG.md +66 -0
  4. data/README.md +51 -60
  5. data/config/routes.rb +0 -59
  6. data/docs/ai-and-mcp.md +9 -8
  7. data/docs/configuration.md +57 -71
  8. data/docs/faq.md +17 -25
  9. data/docs/getting-started.md +105 -117
  10. data/docs/replacing-nightwatch.md +8 -7
  11. data/docs/replacing-sentry.md +12 -16
  12. data/docs/security.md +14 -20
  13. data/docs/self-hosting.md +24 -22
  14. data/docs/testing.md +6 -14
  15. data/docs/troubleshooting.md +12 -46
  16. data/lib/generators/railwatch/install/install_generator.rb +12 -240
  17. data/lib/generators/railwatch/install/templates/initializer.rb.tt +10 -40
  18. data/lib/generators/railwatch/install/templates/post-deploy +0 -7
  19. data/lib/railwatch/authentication.rb +6 -5
  20. data/lib/railwatch/buffer.rb +9 -0
  21. data/lib/railwatch/configuration.rb +21 -151
  22. data/lib/railwatch/engine.rb +9 -118
  23. data/lib/railwatch/patches.rb +0 -11
  24. data/lib/railwatch/reporter.rb +92 -21
  25. data/lib/railwatch/sampler.rb +10 -2
  26. data/lib/railwatch/subscribers/process_info.rb +1 -2
  27. data/lib/railwatch/version.rb +1 -1
  28. data/lib/railwatch.rb +8 -64
  29. data/lib/tasks/railwatch_tasks.rake +3 -325
  30. data/llms.txt +23 -25
  31. metadata +3 -453
  32. data/app/channels/railwatch/environment_channel.rb +0 -62
  33. data/app/controllers/concerns/railwatch/telemetry_identity.rb +0 -25
  34. data/app/controllers/railwatch/alerts_controller.rb +0 -48
  35. data/app/controllers/railwatch/anomaly_rules_controller.rb +0 -31
  36. data/app/controllers/railwatch/attachments_controller.rb +0 -22
  37. data/app/controllers/railwatch/broadcasts_controller.rb +0 -15
  38. data/app/controllers/railwatch/cache_events_controller.rb +0 -30
  39. data/app/controllers/railwatch/commands_controller.rb +0 -16
  40. data/app/controllers/railwatch/comments_controller.rb +0 -11
  41. data/app/controllers/railwatch/dashboard_controller.rb +0 -70
  42. data/app/controllers/railwatch/deploys_controller.rb +0 -34
  43. data/app/controllers/railwatch/deprecations_controller.rb +0 -54
  44. data/app/controllers/railwatch/environment_scoped.rb +0 -81
  45. data/app/controllers/railwatch/exceptions_controller.rb +0 -52
  46. data/app/controllers/railwatch/executions_controller.rb +0 -13
  47. data/app/controllers/railwatch/issues_controller.rb +0 -258
  48. data/app/controllers/railwatch/jobs_controller.rb +0 -63
  49. data/app/controllers/railwatch/llm_calls_controller.rb +0 -124
  50. data/app/controllers/railwatch/logs_controller.rb +0 -40
  51. data/app/controllers/railwatch/mails_controller.rb +0 -19
  52. data/app/controllers/railwatch/notifications_controller.rb +0 -11
  53. data/app/controllers/railwatch/outgoing_requests_controller.rb +0 -19
  54. data/app/controllers/railwatch/overview_controller.rb +0 -38
  55. data/app/controllers/railwatch/people_controller.rb +0 -25
  56. data/app/controllers/railwatch/processes_controller.rb +0 -33
  57. data/app/controllers/railwatch/profiles_controller.rb +0 -65
  58. data/app/controllers/railwatch/queries_controller.rb +0 -59
  59. data/app/controllers/railwatch/releases_controller.rb +0 -75
  60. data/app/controllers/railwatch/requests_controller.rb +0 -37
  61. data/app/controllers/railwatch/saved_views_controller.rb +0 -53
  62. data/app/controllers/railwatch/scheduled_tasks_controller.rb +0 -45
  63. data/app/controllers/railwatch/spans_controller.rb +0 -47
  64. data/app/controllers/railwatch/storage_ops_controller.rb +0 -15
  65. data/app/controllers/railwatch/tenants_controller.rb +0 -44
  66. data/app/controllers/railwatch/thresholds_controller.rb +0 -47
  67. data/app/controllers/railwatch/traces_controller.rb +0 -72
  68. data/app/controllers/railwatch/transactions_controller.rb +0 -21
  69. data/app/controllers/railwatch/view_renders_controller.rb +0 -28
  70. data/app/controllers/railwatch/visits_controller.rb +0 -53
  71. data/app/frontend/components/app-content.tsx +0 -26
  72. data/app/frontend/components/app-logo-icon.tsx +0 -23
  73. data/app/frontend/components/app-logo.tsx +0 -14
  74. data/app/frontend/components/app-shell.tsx +0 -38
  75. data/app/frontend/components/app-sidebar-header.tsx +0 -54
  76. data/app/frontend/components/app-sidebar.tsx +0 -94
  77. data/app/frontend/components/app-wordmark.tsx +0 -29
  78. data/app/frontend/components/breadcrumbs.tsx +0 -46
  79. data/app/frontend/components/icon.tsx +0 -16
  80. data/app/frontend/components/input-error.tsx +0 -18
  81. data/app/frontend/components/nav-footer.tsx +0 -46
  82. data/app/frontend/components/nav-main.tsx +0 -41
  83. data/app/frontend/components/nav-user.tsx +0 -50
  84. data/app/frontend/components/railwatch/chart-hover.tsx +0 -34
  85. data/app/frontend/components/railwatch/chart-panel.tsx +0 -170
  86. data/app/frontend/components/railwatch/code.tsx +0 -28
  87. data/app/frontend/components/railwatch/command-palette.tsx +0 -132
  88. data/app/frontend/components/railwatch/copy-block.tsx +0 -52
  89. data/app/frontend/components/railwatch/copy-id.tsx +0 -49
  90. data/app/frontend/components/railwatch/cursor-load-more.tsx +0 -39
  91. data/app/frontend/components/railwatch/data-table.tsx +0 -195
  92. data/app/frontend/components/railwatch/digit-roll.tsx +0 -312
  93. data/app/frontend/components/railwatch/empty-state.tsx +0 -159
  94. data/app/frontend/components/railwatch/env-nav.tsx +0 -130
  95. data/app/frontend/components/railwatch/env-switcher.tsx +0 -145
  96. data/app/frontend/components/railwatch/filter-bar.tsx +0 -153
  97. data/app/frontend/components/railwatch/flamegraph.tsx +0 -324
  98. data/app/frontend/components/railwatch/frames.tsx +0 -295
  99. data/app/frontend/components/railwatch/json-viewer.tsx +0 -159
  100. data/app/frontend/components/railwatch/live-dot.tsx +0 -78
  101. data/app/frontend/components/railwatch/live-toggle.tsx +0 -76
  102. data/app/frontend/components/railwatch/metric-chart.tsx +0 -112
  103. data/app/frontend/components/railwatch/mobile-page-switcher.tsx +0 -89
  104. data/app/frontend/components/railwatch/onboarding.tsx +0 -91
  105. data/app/frontend/components/railwatch/origin-identity.tsx +0 -84
  106. data/app/frontend/components/railwatch/page-header.tsx +0 -42
  107. data/app/frontend/components/railwatch/percentile-picker.tsx +0 -18
  108. data/app/frontend/components/railwatch/relative-time.tsx +0 -18
  109. data/app/frontend/components/railwatch/saved-views.tsx +0 -232
  110. data/app/frontend/components/railwatch/segmented.tsx +0 -64
  111. data/app/frontend/components/railwatch/series-chart.tsx +0 -400
  112. data/app/frontend/components/railwatch/shortcuts-dialog.tsx +0 -89
  113. data/app/frontend/components/railwatch/signal-toast.tsx +0 -121
  114. data/app/frontend/components/railwatch/sort-header.tsx +0 -37
  115. data/app/frontend/components/railwatch/source-link.tsx +0 -57
  116. data/app/frontend/components/railwatch/sparkline-cell.tsx +0 -14
  117. data/app/frontend/components/railwatch/sql-block.tsx +0 -116
  118. data/app/frontend/components/railwatch/stat.tsx +0 -185
  119. data/app/frontend/components/railwatch/status-badge.tsx +0 -79
  120. data/app/frontend/components/railwatch/step-picker.tsx +0 -57
  121. data/app/frontend/components/railwatch/timeline.tsx +0 -405
  122. data/app/frontend/components/railwatch/utilisation-bar.tsx +0 -44
  123. data/app/frontend/components/railwatch/window-picker.tsx +0 -147
  124. data/app/frontend/components/ui/avatar.tsx +0 -51
  125. data/app/frontend/components/ui/badge.tsx +0 -46
  126. data/app/frontend/components/ui/breadcrumb.tsx +0 -109
  127. data/app/frontend/components/ui/button.tsx +0 -60
  128. data/app/frontend/components/ui/card.tsx +0 -92
  129. data/app/frontend/components/ui/chart.tsx +0 -374
  130. data/app/frontend/components/ui/checkbox.tsx +0 -32
  131. data/app/frontend/components/ui/collapsible.tsx +0 -31
  132. data/app/frontend/components/ui/command.tsx +0 -182
  133. data/app/frontend/components/ui/dialog.tsx +0 -158
  134. data/app/frontend/components/ui/dropdown-menu.tsx +0 -255
  135. data/app/frontend/components/ui/hover-card.tsx +0 -42
  136. data/app/frontend/components/ui/input.tsx +0 -21
  137. data/app/frontend/components/ui/label.tsx +0 -24
  138. data/app/frontend/components/ui/popover.tsx +0 -87
  139. data/app/frontend/components/ui/select.tsx +0 -185
  140. data/app/frontend/components/ui/separator.tsx +0 -28
  141. data/app/frontend/components/ui/sheet.tsx +0 -137
  142. data/app/frontend/components/ui/sidebar.tsx +0 -726
  143. data/app/frontend/components/ui/skeleton.tsx +0 -13
  144. data/app/frontend/components/ui/sonner.tsx +0 -41
  145. data/app/frontend/components/ui/table.tsx +0 -114
  146. data/app/frontend/components/ui/tabs.tsx +0 -91
  147. data/app/frontend/components/ui/textarea.tsx +0 -18
  148. data/app/frontend/components/ui/tooltip.tsx +0 -61
  149. data/app/frontend/components/user-info.tsx +0 -32
  150. data/app/frontend/components/user-menu-content.tsx +0 -94
  151. data/app/frontend/entrypoints/application.css +0 -244
  152. data/app/frontend/entrypoints/inertia.tsx +0 -92
  153. data/app/frontend/fonts/CommitMono-Bold.woff2 +0 -0
  154. data/app/frontend/fonts/CommitMono-Regular.woff2 +0 -0
  155. data/app/frontend/fonts/Roboto-Black.woff2 +0 -0
  156. data/app/frontend/fonts/Roboto-Bold.woff2 +0 -0
  157. data/app/frontend/fonts/Roboto-Medium.woff2 +0 -0
  158. data/app/frontend/fonts/Roboto-Regular.woff2 +0 -0
  159. data/app/frontend/hooks/use-appearance.tsx +0 -67
  160. data/app/frontend/hooks/use-clipboard.ts +0 -32
  161. data/app/frontend/hooks/use-flash.tsx +0 -23
  162. data/app/frontend/hooks/use-hotkeys.ts +0 -63
  163. data/app/frontend/hooks/use-initials.tsx +0 -15
  164. data/app/frontend/hooks/use-live.ts +0 -205
  165. data/app/frontend/hooks/use-mobile-navigation.ts +0 -8
  166. data/app/frontend/hooks/use-mobile.ts +0 -21
  167. data/app/frontend/hooks/use-percentile.ts +0 -24
  168. data/app/frontend/hooks/use-reduced-motion.ts +0 -25
  169. data/app/frontend/hooks/use-row-nav.ts +0 -106
  170. data/app/frontend/hooks/use-step.ts +0 -27
  171. data/app/frontend/hooks/use-ticker.ts +0 -12
  172. data/app/frontend/hooks/use-window.ts +0 -58
  173. data/app/frontend/layouts/app/app-sidebar-layout.tsx +0 -107
  174. data/app/frontend/layouts/app-layout.tsx +0 -21
  175. data/app/frontend/layouts/env-layout.tsx +0 -53
  176. data/app/frontend/layouts/persistent-layout.tsx +0 -18
  177. data/app/frontend/lib/cable.ts +0 -10
  178. data/app/frontend/lib/env-pages.ts +0 -236
  179. data/app/frontend/lib/execution-path.ts +0 -55
  180. data/app/frontend/lib/explain.ts +0 -50
  181. data/app/frontend/lib/filter.ts +0 -25
  182. data/app/frontend/lib/flamegraph.ts +0 -155
  183. data/app/frontend/lib/format.ts +0 -71
  184. data/app/frontend/lib/keyboard.ts +0 -18
  185. data/app/frontend/lib/mount.ts +0 -23
  186. data/app/frontend/lib/source-link.ts +0 -217
  187. data/app/frontend/lib/utils.ts +0 -6
  188. data/app/frontend/pages/alerts/index.tsx +0 -171
  189. data/app/frontend/pages/broadcasts/index.tsx +0 -171
  190. data/app/frontend/pages/cache_events/index.tsx +0 -127
  191. data/app/frontend/pages/commands/index.tsx +0 -143
  192. data/app/frontend/pages/deploys/index.tsx +0 -94
  193. data/app/frontend/pages/deploys/show.tsx +0 -309
  194. data/app/frontend/pages/deprecations/index.tsx +0 -176
  195. data/app/frontend/pages/exceptions/index.tsx +0 -271
  196. data/app/frontend/pages/executions/show.tsx +0 -821
  197. data/app/frontend/pages/issues/index.tsx +0 -399
  198. data/app/frontend/pages/issues/show.tsx +0 -1367
  199. data/app/frontend/pages/jobs/index.tsx +0 -381
  200. data/app/frontend/pages/jobs/klass.tsx +0 -215
  201. data/app/frontend/pages/llm_calls/index.tsx +0 -664
  202. data/app/frontend/pages/logs/index.tsx +0 -303
  203. data/app/frontend/pages/mails/index.tsx +0 -218
  204. data/app/frontend/pages/notifications/index.tsx +0 -161
  205. data/app/frontend/pages/outgoing_requests/index.tsx +0 -309
  206. data/app/frontend/pages/overview/show.tsx +0 -333
  207. data/app/frontend/pages/people/index.tsx +0 -102
  208. data/app/frontend/pages/people/show.tsx +0 -149
  209. data/app/frontend/pages/processes/index.tsx +0 -335
  210. data/app/frontend/pages/profiles/index.tsx +0 -110
  211. data/app/frontend/pages/profiles/show.tsx +0 -105
  212. data/app/frontend/pages/queries/index.tsx +0 -492
  213. data/app/frontend/pages/queries/show.tsx +0 -276
  214. data/app/frontend/pages/releases/index.tsx +0 -190
  215. data/app/frontend/pages/releases/release-health.tsx +0 -68
  216. data/app/frontend/pages/releases/release-path.ts +0 -25
  217. data/app/frontend/pages/releases/show.tsx +0 -262
  218. data/app/frontend/pages/requests/index.tsx +0 -235
  219. data/app/frontend/pages/requests/route.tsx +0 -159
  220. data/app/frontend/pages/scheduled_tasks/index.tsx +0 -220
  221. data/app/frontend/pages/spans/index.tsx +0 -229
  222. data/app/frontend/pages/spans/show.tsx +0 -220
  223. data/app/frontend/pages/storage_ops/index.tsx +0 -150
  224. data/app/frontend/pages/tenants/index.tsx +0 -236
  225. data/app/frontend/pages/tenants/show.tsx +0 -477
  226. data/app/frontend/pages/tenants/tenant-path.ts +0 -26
  227. data/app/frontend/pages/thresholds/index.tsx +0 -414
  228. data/app/frontend/pages/traces/show.tsx +0 -208
  229. data/app/frontend/pages/transactions/index.tsx +0 -220
  230. data/app/frontend/pages/view_renders/index.tsx +0 -241
  231. data/app/frontend/pages/visits/index.tsx +0 -321
  232. data/app/frontend/public/icon.png +0 -0
  233. data/app/frontend/public/icon.svg +0 -5
  234. data/app/frontend/routes/index.d.ts +0 -2136
  235. data/app/frontend/routes/index.js +0 -2112
  236. data/app/frontend/types/globals.d.ts +0 -9
  237. data/app/frontend/types/index.ts +0 -288
  238. data/app/frontend/types/vite-env.d.ts +0 -9
  239. data/app/helpers/railwatch/assets_helper.rb +0 -52
  240. data/app/jobs/railwatch/anomaly_scan_job.rb +0 -11
  241. data/app/jobs/railwatch/application_job.rb +0 -7
  242. data/app/jobs/railwatch/auto_resolve_issues_job.rb +0 -19
  243. data/app/jobs/railwatch/check_scheduled_tasks_job.rb +0 -113
  244. data/app/jobs/railwatch/detect_anomalies_job.rb +0 -171
  245. data/app/jobs/railwatch/detect_performance_issues_job.rb +0 -95
  246. data/app/jobs/railwatch/group_exceptions_job.rb +0 -91
  247. data/app/jobs/railwatch/optimize_telemetry_job.rb +0 -23
  248. data/app/jobs/railwatch/performance_scan_job.rb +0 -11
  249. data/app/jobs/railwatch/prune_telemetry_job.rb +0 -129
  250. data/app/jobs/railwatch/release_health_rollup_job.rb +0 -64
  251. data/app/jobs/railwatch/rollup_catchup_job.rb +0 -21
  252. data/app/jobs/railwatch/rollup_job.rb +0 -132
  253. data/app/jobs/railwatch/scheduled_task_scan_job.rb +0 -11
  254. data/app/models/concerns/railwatch/detection_snapshotting.rb +0 -20
  255. data/app/models/railwatch/alert.rb +0 -229
  256. data/app/models/railwatch/alert_rule.rb +0 -121
  257. data/app/models/railwatch/anomaly_rule.rb +0 -51
  258. data/app/models/railwatch/application.rb +0 -44
  259. data/app/models/railwatch/application_record.rb +0 -33
  260. data/app/models/railwatch/comment.rb +0 -36
  261. data/app/models/railwatch/deploy.rb +0 -67
  262. data/app/models/railwatch/environment.rb +0 -68
  263. data/app/models/railwatch/execution_presenter.rb +0 -185
  264. data/app/models/railwatch/filter_query.rb +0 -184
  265. data/app/models/railwatch/followup_receipt.rb +0 -27
  266. data/app/models/railwatch/ingest/batch.rb +0 -355
  267. data/app/models/railwatch/ingest/mapper.rb +0 -585
  268. data/app/models/railwatch/ingest/payload.rb +0 -96
  269. data/app/models/railwatch/ingest/rollup_absorber.rb +0 -170
  270. data/app/models/railwatch/ingest/writer.rb +0 -137
  271. data/app/models/railwatch/issue.rb +0 -277
  272. data/app/models/railwatch/issue_activity.rb +0 -23
  273. data/app/models/railwatch/issue_detection_presenter.rb +0 -309
  274. data/app/models/railwatch/issue_detection_snapshot.rb +0 -77
  275. data/app/models/railwatch/maintenance_task.rb +0 -53
  276. data/app/models/railwatch/saved_view.rb +0 -63
  277. data/app/models/railwatch/telemetry/aggregations.rb +0 -251
  278. data/app/models/railwatch/telemetry/attachment.rb +0 -31
  279. data/app/models/railwatch/telemetry/bounded_gzip.rb +0 -101
  280. data/app/models/railwatch/telemetry/broadcast.rb +0 -13
  281. data/app/models/railwatch/telemetry/cache_event.rb +0 -16
  282. data/app/models/railwatch/telemetry/child.rb +0 -53
  283. data/app/models/railwatch/telemetry/cursor_page.rb +0 -99
  284. data/app/models/railwatch/telemetry/deprecation.rb +0 -13
  285. data/app/models/railwatch/telemetry/enqueued_job.rb +0 -13
  286. data/app/models/railwatch/telemetry/exception.rb +0 -28
  287. data/app/models/railwatch/telemetry/execution.rb +0 -152
  288. data/app/models/railwatch/telemetry/export_delivery.rb +0 -32
  289. data/app/models/railwatch/telemetry/export_destination.rb +0 -66
  290. data/app/models/railwatch/telemetry/health_sample.rb +0 -72
  291. data/app/models/railwatch/telemetry/ingest_batch.rb +0 -31
  292. data/app/models/railwatch/telemetry/llm_call.rb +0 -37
  293. data/app/models/railwatch/telemetry/log.rb +0 -91
  294. data/app/models/railwatch/telemetry/mail.rb +0 -13
  295. data/app/models/railwatch/telemetry/n_plus_one.rb +0 -92
  296. data/app/models/railwatch/telemetry/notification.rb +0 -13
  297. data/app/models/railwatch/telemetry/outgoing_request.rb +0 -13
  298. data/app/models/railwatch/telemetry/person.rb +0 -50
  299. data/app/models/railwatch/telemetry/process.rb +0 -10
  300. data/app/models/railwatch/telemetry/profile.rb +0 -37
  301. data/app/models/railwatch/telemetry/query.rb +0 -57
  302. data/app/models/railwatch/telemetry/query_shape.rb +0 -45
  303. data/app/models/railwatch/telemetry/release_health.rb +0 -69
  304. data/app/models/railwatch/telemetry/rollup.rb +0 -157
  305. data/app/models/railwatch/telemetry/session.rb +0 -24
  306. data/app/models/railwatch/telemetry/span.rb +0 -19
  307. data/app/models/railwatch/telemetry/storage_op.rb +0 -13
  308. data/app/models/railwatch/telemetry/tenant.rb +0 -240
  309. data/app/models/railwatch/telemetry/transaction.rb +0 -13
  310. data/app/models/railwatch/telemetry/view_render.rb +0 -13
  311. data/app/models/railwatch/telemetry/visit.rb +0 -24
  312. data/app/models/railwatch/telemetry_record.rb +0 -107
  313. data/app/models/railwatch/threshold.rb +0 -66
  314. data/app/models/railwatch/user.rb +0 -47
  315. data/app/models/railwatch/viewer.rb +0 -13
  316. data/app/models/railwatch/window.rb +0 -66
  317. data/app/views/layouts/railwatch/dashboard.html.erb +0 -25
  318. data/db/railwatch_migrate/20260916000000_create_railwatch_tables.rb +0 -151
  319. data/db/railwatch_migrate/20260917000000_create_railwatch_maintenance_tasks.rb +0 -18
  320. data/db/railwatch_migrate/20260917120000_create_railwatch_followup_receipts.rb +0 -20
  321. data/db/railwatch_migrate/20260918120000_widen_host_user_ids.rb +0 -71
  322. data/db/railwatch_telemetry_migrate/20260903000000_enable_incremental_vacuum.rb +0 -62
  323. data/db/railwatch_telemetry_migrate/20260903000001_create_telemetry.rb +0 -481
  324. data/db/railwatch_telemetry_migrate/20260903000002_rename_tenant_to_app_tenant.rb +0 -14
  325. data/db/railwatch_telemetry_migrate/20260903000003_add_statement_count_to_transactions.rb +0 -9
  326. data/db/railwatch_telemetry_migrate/20260903000004_add_role_and_channel.rb +0 -10
  327. data/db/railwatch_telemetry_migrate/20260903000005_add_locals_to_exceptions.rb +0 -9
  328. data/db/railwatch_telemetry_migrate/20260903000006_add_spans_health_vitals_and_fts.rb +0 -82
  329. data/db/railwatch_telemetry_migrate/20260903000007_rename_span_attributes_to_payload.rb +0 -10
  330. data/db/railwatch_telemetry_migrate/20260903000008_add_profiles_and_attachments.rb +0 -60
  331. data/db/railwatch_telemetry_migrate/20260903000009_add_truncated_to_attachments.rb +0 -9
  332. data/db/railwatch_telemetry_migrate/20260903000010_create_sessions_and_release_health.rb +0 -51
  333. data/db/railwatch_telemetry_migrate/20260903000011_add_fingerprint_to_exceptions.rb +0 -11
  334. data/db/railwatch_telemetry_migrate/20260904000012_add_failed_to_broadcasts.rb +0 -10
  335. data/db/railwatch_telemetry_migrate/20260904010000_add_filter_cursor_indexes.rb +0 -37
  336. data/db/railwatch_telemetry_migrate/20260904020000_add_n_plus_ones_execution_id_index.rb +0 -11
  337. data/db/railwatch_telemetry_migrate/20260904120000_create_query_shapes.rb +0 -15
  338. data/db/railwatch_telemetry_migrate/20260906120000_add_backpressure_factor_to_ingest_batches.rb +0 -9
  339. data/db/railwatch_telemetry_migrate/20260907000000_rename_lantern_version_on_processes.rb +0 -10
  340. data/db/railwatch_telemetry_migrate/20260913000000_rename_nightrail_version_on_processes.rb +0 -9
  341. data/db/railwatch_telemetry_migrate/20260914000000_drop_orphan_durable_ingest_tables.rb +0 -18
  342. data/db/railwatch_telemetry_migrate/20260914010000_drop_orphan_durable_ingest_columns.rb +0 -25
  343. data/db/railwatch_telemetry_migrate/20260915000000_create_llm_calls.rb +0 -55
  344. data/db/railwatch_telemetry_migrate/20260915120000_add_detail_to_llm_calls.rb +0 -21
  345. data/db/railwatch_telemetry_migrate/20260915200000_add_explained_index_to_queries.rb +0 -16
  346. data/db/railwatch_telemetry_migrate/20260915210000_add_slowest_index_to_queries.rb +0 -18
  347. data/db/railwatch_telemetry_migrate/20260917010000_add_batch_ledger_to_ingest_batches.rb +0 -16
  348. data/db/railwatch_telemetry_migrate/20260919000100_create_export_queue.rb +0 -146
  349. data/db/railwatch_telemetry_migrate/20260922000000_add_covering_indexes_for_dashboard_aggregates.rb +0 -34
  350. data/db/railwatch_telemetry_migrate/20260923000000_add_people_index.rb +0 -15
  351. data/db/railwatch_telemetry_migrate/20260925000000_add_tenant_summary_index.rb +0 -17
  352. data/db/railwatch_telemetry_migrate/20261008000000_add_in_transaction_to_outgoing_requests.rb +0 -10
  353. data/docs/embedded.md +0 -503
  354. data/lib/puma/plugin/railwatch.rb +0 -215
  355. data/lib/railwatch/dashboard_assets.rb +0 -45
  356. data/lib/railwatch/embedded.rb +0 -50
  357. data/lib/railwatch/export/client.rb +0 -124
  358. data/lib/railwatch/export/lease.rb +0 -56
  359. data/lib/railwatch/export/outbox.rb +0 -361
  360. data/lib/railwatch/export/policy.rb +0 -79
  361. data/lib/railwatch/export/sender.rb +0 -130
  362. data/lib/railwatch/ingest_request_body_limit.rb +0 -10
  363. data/lib/railwatch/maintenance.rb +0 -194
  364. data/lib/railwatch/patches/migration_busy_timeout.rb +0 -73
  365. data/lib/railwatch/transport/local.rb +0 -78
  366. data/lib/railwatch/transport/socket.rb +0 -183
  367. data/lib/railwatch/writer.rb +0 -370
  368. data/public/railwatch/assets/CommitMono-Bold-D6h61ieg.woff2 +0 -0
  369. data/public/railwatch/assets/CommitMono-Regular-zr8w7Obm.woff2 +0 -0
  370. data/public/railwatch/assets/Roboto-Black-auA4GeOK.woff2 +0 -0
  371. data/public/railwatch/assets/Roboto-Bold-CJLnO8j1.woff2 +0 -0
  372. data/public/railwatch/assets/Roboto-Medium-Cm2bwKpj.woff2 +0 -0
  373. data/public/railwatch/assets/Roboto-Regular-Chaq1-PV.woff2 +0 -0
  374. data/public/railwatch/assets/app-layout-by7G3sa1.js +0 -41
  375. data/public/railwatch/assets/application-Cc5v_Vk9.css +0 -1
  376. data/public/railwatch/assets/arrow-up-DCZ7emux.js +0 -1
  377. data/public/railwatch/assets/braces-CykT5_5u.js +0 -1
  378. data/public/railwatch/assets/card-C-PPSA0g.js +0 -1
  379. data/public/railwatch/assets/chart-Cov79YC5.js +0 -39
  380. data/public/railwatch/assets/chart-hover-DdK4E-2h.js +0 -1
  381. data/public/railwatch/assets/chart-panel-BPaYRXJB.js +0 -1
  382. data/public/railwatch/assets/code-BYjC4YZ_.js +0 -1
  383. data/public/railwatch/assets/copy-id-LPfoj9cC.js +0 -1
  384. data/public/railwatch/assets/cursor-load-more-DSdhgpGl.js +0 -1
  385. data/public/railwatch/assets/data-table-CQ_YRWQs.js +0 -1
  386. data/public/railwatch/assets/env-layout-CdMWbUkm.js +0 -1
  387. data/public/railwatch/assets/execution-path-7MYt-Qbn.js +0 -1
  388. data/public/railwatch/assets/filter-bar-CrzpQ_vD.js +0 -1
  389. data/public/railwatch/assets/flamegraph-BIFEdTAg.js +0 -2
  390. data/public/railwatch/assets/format-B9SDkrWj.js +0 -1
  391. data/public/railwatch/assets/frames-C5t-x4f0.js +0 -1
  392. data/public/railwatch/assets/index--NQ7us-L.js +0 -1
  393. data/public/railwatch/assets/index-2kgYe_99.js +0 -1
  394. data/public/railwatch/assets/index-4YEPHxtO.js +0 -1
  395. data/public/railwatch/assets/index-AOCC1GDJ.js +0 -1
  396. data/public/railwatch/assets/index-BEo-u0eO.js +0 -1
  397. data/public/railwatch/assets/index-BJ1V3w2N.js +0 -1
  398. data/public/railwatch/assets/index-BPoHsBKb.js +0 -1
  399. data/public/railwatch/assets/index-BZzyGUM3.js +0 -1
  400. data/public/railwatch/assets/index-BgVq7yKL.js +0 -1
  401. data/public/railwatch/assets/index-BqFGEZvW.js +0 -1
  402. data/public/railwatch/assets/index-ByuHJ1CH.js +0 -1
  403. data/public/railwatch/assets/index-C2j50AL2.js +0 -1
  404. data/public/railwatch/assets/index-CLEdTkvW.js +0 -1
  405. data/public/railwatch/assets/index-CWgq8qBu.js +0 -1
  406. data/public/railwatch/assets/index-CXQ7_oVj.js +0 -2
  407. data/public/railwatch/assets/index-CmyYrjS2.js +0 -1
  408. data/public/railwatch/assets/index-Cn9_HjDo.js +0 -1
  409. data/public/railwatch/assets/index-CsMAm9UK.js +0 -1
  410. data/public/railwatch/assets/index-D0Kk_oGY.js +0 -1
  411. data/public/railwatch/assets/index-DC9ZptJh.js +0 -1
  412. data/public/railwatch/assets/index-DNzg7jmV.js +0 -1
  413. data/public/railwatch/assets/index-DQ8uJO5f.js +0 -1
  414. data/public/railwatch/assets/index-Deq14BxB.js +0 -1
  415. data/public/railwatch/assets/index-IaV3Blzp.js +0 -1
  416. data/public/railwatch/assets/index-TYNWcJQs.js +0 -1
  417. data/public/railwatch/assets/index-WonH2fcZ.js +0 -1
  418. data/public/railwatch/assets/index-WxWUaH_d.js +0 -1
  419. data/public/railwatch/assets/index-mUNrl4ky.js +0 -1
  420. data/public/railwatch/assets/inertia-DfNRARuA.js +0 -97
  421. data/public/railwatch/assets/json-viewer-DMsF5glp.js +0 -1
  422. data/public/railwatch/assets/klass-CmHYk5rz.js +0 -1
  423. data/public/railwatch/assets/origin-identity-1R3bqa3R.js +0 -1
  424. data/public/railwatch/assets/percentile-picker-73z5PAps.js +0 -1
  425. data/public/railwatch/assets/relative-time-Dsc_cCdX.js +0 -1
  426. data/public/railwatch/assets/release-health-BV5OaOWd.js +0 -1
  427. data/public/railwatch/assets/route-CWh58C7K.js +0 -1
  428. data/public/railwatch/assets/segmented-DB4zh-Am.js +0 -1
  429. data/public/railwatch/assets/select-DBKjFlOX.js +0 -1
  430. data/public/railwatch/assets/series-chart-BaiDG795.js +0 -1
  431. data/public/railwatch/assets/show-B4ICCFGh.js +0 -1
  432. data/public/railwatch/assets/show-B5lOU9Gv.js +0 -1
  433. data/public/railwatch/assets/show-BYPB74t4.js +0 -2
  434. data/public/railwatch/assets/show-C7A0m2M8.js +0 -1
  435. data/public/railwatch/assets/show-DT5X1zqE.js +0 -1
  436. data/public/railwatch/assets/show-D_NBdXsn.js +0 -2
  437. data/public/railwatch/assets/show-DgPCnRg8.js +0 -1
  438. data/public/railwatch/assets/show-Yl69HF0x.js +0 -1
  439. data/public/railwatch/assets/show-_QxuUQCm.js +0 -3
  440. data/public/railwatch/assets/show-g__jnN36.js +0 -1
  441. data/public/railwatch/assets/show-l4ztfjl9.js +0 -1
  442. data/public/railwatch/assets/sort-header-BcG8ulEH.js +0 -1
  443. data/public/railwatch/assets/source-link-B2183i2-.js +0 -1
  444. data/public/railwatch/assets/sparkline-cell-B47ipscB.js +0 -1
  445. data/public/railwatch/assets/stat-DT2hhsqS.js +0 -1
  446. data/public/railwatch/assets/status-badge-CmbRKlY7.js +0 -1
  447. data/public/railwatch/assets/tenant-path-C4rNGauE.js +0 -1
  448. data/public/railwatch/assets/use-clipboard-oTNeP4ax.js +0 -1
  449. data/public/railwatch/assets/use-live-CLZZj245.js +0 -1
  450. data/public/railwatch/icon.png +0 -0
  451. data/public/railwatch/icon.svg +0 -5
  452. data/public/railwatch/manifest.json +0 -1245
  453. data/public/railwatch/rails-vite.json +0 -1
  454. /data/{app/frontend/lib → lib/generators/railwatch/install/templates}/railwatch.ts +0 -0
@@ -1,1367 +0,0 @@
1
- import { Form, Head, Link, router } from "@inertiajs/react"
2
- import { Check, ClipboardCopy, GitMerge, Globe, Split } from "lucide-react"
3
- import { useEffect, useState } from "react"
4
- import {
5
- Bar,
6
- BarChart,
7
- CartesianGrid,
8
- ReferenceLine,
9
- XAxis,
10
- YAxis,
11
- } from "recharts"
12
-
13
- import { DataTable } from "@/components/railwatch/data-table"
14
- import {
15
- Breadcrumbs,
16
- BrowserBreadcrumbs,
17
- type BrowserCrumb,
18
- ExceptionCard,
19
- } from "@/components/railwatch/frames"
20
- import { JsonViewer } from "@/components/railwatch/json-viewer"
21
- import { RelativeTime } from "@/components/railwatch/relative-time"
22
- import { SourceLink } from "@/components/railwatch/source-link"
23
- import { Stat, StatStrip } from "@/components/railwatch/stat"
24
- import { IssueStatusBadge } from "@/components/railwatch/status-badge"
25
- import { Avatar, AvatarFallback } from "@/components/ui/avatar"
26
- import { Badge } from "@/components/ui/badge"
27
- import { Button } from "@/components/ui/button"
28
- import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
29
- import {
30
- type ChartConfig,
31
- ChartContainer,
32
- ChartTooltip,
33
- ChartTooltipContent,
34
- } from "@/components/ui/chart"
35
- import {
36
- CommandDialog,
37
- CommandEmpty,
38
- CommandGroup,
39
- CommandInput,
40
- CommandItem,
41
- CommandList,
42
- } from "@/components/ui/command"
43
- import {
44
- Select,
45
- SelectContent,
46
- SelectItem,
47
- SelectTrigger,
48
- SelectValue,
49
- } from "@/components/ui/select"
50
- import { Textarea } from "@/components/ui/textarea"
51
- import { useClipboard } from "@/hooks/use-clipboard"
52
- import { useInitials } from "@/hooks/use-initials"
53
- import AppLayout from "@/layouts/app-layout"
54
- import { executionPath } from "@/lib/execution-path"
55
- import { ago, bytes, count, when } from "@/lib/format"
56
- import * as R from "@/routes"
57
- import type { ExceptionDetail, IssueRow, TimelineEntry } from "@/types"
58
-
59
- // A 30-bar chart has no room to print deploy refs: on a week with a
60
- // deploy a day they overprint into noise. The dashed line marks the day
61
- // and the tooltip names what shipped.
62
- function deploysOn(deploys: { ref: string; at: string }[], day: string) {
63
- return deploys.filter((d) => d.at.slice(0, 10) === day).map((d) => d.ref)
64
- }
65
-
66
- const dailyConfig = {
67
- count: { label: "Occurrences", color: "var(--chart-5)" },
68
- } satisfies ChartConfig
69
-
70
- const detectionConfig = {
71
- value: { label: "Measured value", color: "var(--chart-2)" },
72
- } satisfies ChartConfig
73
-
74
- const groupingHints: Record<string, string> = {
75
- report: "Fingerprint passed to Railwatch.report.",
76
- error: "Fingerprint from the error's own #railwatch_fingerprint.",
77
- resolver: "Fingerprint from the app's Railwatch.fingerprint block.",
78
- default:
79
- "Railwatch's default: exception class, top in-app frame, and the message with its variable data removed.",
80
- }
81
-
82
- interface Occurrence {
83
- id: number
84
- message: string
85
- handled: boolean
86
- occurred_at: string
87
- execution_id: string | null
88
- execution_source: string | null
89
- execution_preview: string | null
90
- user_ref: string | null
91
- tenant: string | null
92
- deploy: string | null
93
- server: string | null
94
- }
95
- interface DetectionRecord {
96
- record_type: string
97
- record_id: number
98
- group_hash: string
99
- execution_id: string | null
100
- execution_source: string | null
101
- execution_preview: string | null
102
- name: string
103
- occurred_at: string
104
- duration_ms: number
105
- deploy: string | null
106
- status?: number | null
107
- outcome?: string | null
108
- source?: string | null
109
- connection?: string | null
110
- role?: string | null
111
- drilldown: DetectionDrilldown | null
112
- }
113
- interface DetectionDrilldown {
114
- kind: string
115
- execution_id?: string | null
116
- group_hash?: string | null
117
- }
118
-
119
- // Queries drill into their group; everything else is an execution, so reuse
120
- // the shared execution-path helper (which accepts both the "job" child wire
121
- // name and the "job_attempt" parent kind) rather than a second copy of it.
122
- function detectionPath(
123
- issue: Props["issue"],
124
- drilldown: DetectionDrilldown | null,
125
- ): string | null {
126
- if (!drilldown) return null
127
- if (drilldown.kind === "query") {
128
- return drilldown.group_hash
129
- ? R.applicationEnvironmentQueryPath(
130
- issue.application.id,
131
- issue.environment.id,
132
- drilldown.group_hash,
133
- )
134
- : null
135
- }
136
- return executionPath({
137
- applicationId: issue.application.id,
138
- environmentId: issue.environment.id,
139
- source: drilldown.kind,
140
- executionId: drilldown.execution_id,
141
- })
142
- }
143
- interface DetectionDetail {
144
- kind: "performance" | "anomaly"
145
- available: boolean
146
- message?: string
147
- telemetry_type?: string
148
- telemetry_group_hash?: string
149
- target?: string
150
- metric?: string
151
- unit?: string
152
- count_label: string
153
- breached_windows: number
154
- event_count?: number
155
- window?: { from: string; to: string; minutes: number }
156
- measurement?: { value?: number; limit?: number }
157
- baseline?: {
158
- mean?: number
159
- stddev?: number
160
- sigmas?: number
161
- days?: number
162
- deviation?: number
163
- }
164
- rule?: {
165
- type?: string
166
- task_key?: string
167
- schedule?: string
168
- last_run?: string
169
- expected?: string
170
- }
171
- representative_records?: DetectionRecord[]
172
- trend?: { day: string; value: number | null; count: number }[]
173
- deploy_comparison?: {
174
- deploy: string
175
- events: number
176
- avg_ms: number
177
- max_ms: number
178
- }[]
179
- }
180
- interface Props {
181
- issue: IssueRow & {
182
- first_seen_at: string
183
- resolved_at: string | null
184
- resolved_in_deploy: string | null
185
- regressed_at: string | null
186
- sample: Record<string, unknown>
187
- application: { id: number; name: string }
188
- environment: { id: number; name: string }
189
- }
190
- latest:
191
- (ExceptionDetail & { ruby_version?: string; rails_version?: string }) | null
192
- detection: DetectionDetail | null
193
- occurrences: Occurrence[]
194
- attachments: {
195
- id: number
196
- name: string
197
- content_type: string | null
198
- bytes: number
199
- truncated: boolean
200
- viewable: boolean
201
- occurred_at: string
202
- execution_id: string | null
203
- execution_preview: string | null
204
- }[]
205
- breadcrumbs: TimelineEntry[]
206
- browser_breadcrumbs: BrowserCrumb[]
207
- fingerprint: string[]
208
- fingerprint_source: string | null
209
- distinct_messages: number
210
- top_messages: { message: string; count: number }[]
211
- daily: { day: string; count: number }[]
212
- by_deploy: Record<string, number>
213
- by_tenant: Record<string, number>
214
- comments: { id: number; body: string; user: string; created_at: string }[]
215
- members: { id: number; name: string }[]
216
- deploys: { deploy: string; ref: string; at: string }[]
217
- related_issues: {
218
- id: number
219
- key: string
220
- title: string
221
- status: string
222
- environment: { id: number; name: string }
223
- }[]
224
- activities: {
225
- id: number
226
- kind: string
227
- data: Record<string, unknown>
228
- user: { id: number; name: string } | null
229
- created_at: string
230
- }[]
231
- merged_into: { id: number; key: string; title: string } | null
232
- merged_issues: { id: number; key: string; title: string }[]
233
- alerts: {
234
- id: number
235
- event: string
236
- status: string
237
- sent_at: string | null
238
- error: string | null
239
- integration: { kind: string; name: string }
240
- }[]
241
- }
242
-
243
- function activitySentence(
244
- a: Props["activities"][number],
245
- membersById: Map<number, string>,
246
- ) {
247
- const d = a.data as Record<string, string | number | null>
248
- switch (a.kind) {
249
- case "created":
250
- return "opened this issue"
251
- case "status":
252
- return `changed status ${d.from} → ${d.to}`
253
- case "priority":
254
- return `changed priority ${d.from} → ${d.to}`
255
- case "assignee": {
256
- const from = d.from_id
257
- ? (membersById.get(Number(d.from_id)) ?? "someone")
258
- : "unassigned"
259
- const to = d.to_id
260
- ? (membersById.get(Number(d.to_id)) ?? "someone")
261
- : "unassigned"
262
- return `reassigned ${from} → ${to}`
263
- }
264
- case "merge":
265
- return `merged this issue into ${d.target_key}`
266
- case "absorbed":
267
- return `merged ${d.source_key} into this issue (${d.occurrences} events)`
268
- case "unmerge":
269
- return "unmerged this issue"
270
- case "split": {
271
- const into = Array.isArray(a.data.into) ? (a.data.into as string[]) : []
272
- return d.from_key
273
- ? `split ${d.occurrences} events out of ${d.from_key}`
274
- : `split ${d.occurrences} events by message into ${into.join(", ")}`
275
- }
276
- case "regressed":
277
- return "regressed — a new occurrence reopened this issue"
278
- case "alert":
279
- return `sent a "${d.event}" alert via ${d.integration}`
280
- case "agent":
281
- return `acted via ${String(d.agent ?? "an AI agent")} (MCP)`
282
- case "comment":
283
- return "commented"
284
- default:
285
- return String(a.kind)
286
- }
287
- }
288
-
289
- function copyForAiText(p: Props) {
290
- const i = p.issue
291
- const lines: string[] = []
292
- lines.push(`# ${i.key}: ${i.title}`)
293
- if (i.culprit) lines.push(`Culprit: ${i.culprit}`)
294
- lines.push(`Environment: ${i.application.name} / ${i.environment.name}`)
295
- if (p.detection?.available) {
296
- lines.push("")
297
- lines.push("## Detector measurement")
298
- lines.push(
299
- `${p.detection.metric} for ${p.detection.target}: ${measurement(p.detection.measurement?.value, p.detection.unit)}`,
300
- )
301
- if (p.detection.measurement?.limit != null) {
302
- lines.push(
303
- `Limit: ${measurement(p.detection.measurement.limit, p.detection.unit)}`,
304
- )
305
- }
306
- if (p.detection.baseline?.mean != null) {
307
- lines.push(
308
- `Baseline: ${measurement(p.detection.baseline.mean, p.detection.unit)} (${p.detection.baseline.sigmas}σ)`,
309
- )
310
- }
311
- lines.push(
312
- `Underlying telemetry: ${p.detection.telemetry_type} ${p.detection.telemetry_group_hash}`,
313
- )
314
- }
315
- if (p.latest) {
316
- lines.push("")
317
- lines.push(`## ${p.latest.class_name}`)
318
- lines.push(p.latest.message)
319
- const appFrames = p.latest.frames.filter((f) => f.in_app)
320
- if (appFrames.length > 0) {
321
- lines.push("")
322
- lines.push("### Frames")
323
- for (const f of appFrames) {
324
- lines.push(`${f.file}:${f.line} in \`${f.function}\``)
325
- if (f.code) {
326
- for (const [ln, src] of Object.entries(f.code)) {
327
- lines.push(`${ln}: ${src}`)
328
- }
329
- }
330
- }
331
- }
332
- if (p.latest.context) {
333
- lines.push("")
334
- lines.push("### Context")
335
- lines.push(String(p.latest.context))
336
- }
337
- }
338
- return lines.join("\n")
339
- }
340
-
341
- function measurement(value: number | undefined, unit: string | undefined) {
342
- if (value == null) return "–"
343
- if (unit === "milliseconds") return `${value.toLocaleString()} ms`
344
- if (unit === "percent") return `${value.toLocaleString()}%`
345
- if (unit === "events_per_minute") return `${value.toLocaleString()} / min`
346
- return value.toLocaleString()
347
- }
348
-
349
- function DetectionPanel({
350
- issue,
351
- detail,
352
- }: {
353
- issue: Props["issue"]
354
- detail: DetectionDetail
355
- }) {
356
- // Issues opened before typed detection existed still carry their original
357
- // detector sample. Show it rather than replacing real detail with a notice.
358
- if (!detail.available) {
359
- return (
360
- <Card>
361
- <CardHeader>
362
- <CardTitle>Detector sample</CardTitle>
363
- </CardHeader>
364
- <CardContent>
365
- <JsonViewer data={issue.sample} />
366
- </CardContent>
367
- </Card>
368
- )
369
- }
370
- const records = detail.representative_records ?? []
371
- const deploys = detail.deploy_comparison ?? []
372
- const trend = detail.trend ?? []
373
- const missedSchedule = detail.metric === "missed"
374
- return (
375
- <>
376
- <Card>
377
- <CardHeader>
378
- <CardTitle>
379
- {missedSchedule
380
- ? "Missed scheduled task"
381
- : detail.kind === "anomaly"
382
- ? "Anomaly measurement"
383
- : "Threshold breach"}
384
- </CardTitle>
385
- </CardHeader>
386
- <CardContent className="space-y-4">
387
- <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
388
- <div>
389
- <p className="text-muted-foreground text-xs">Target</p>
390
- <p className="font-mono text-sm">{detail.target}</p>
391
- </div>
392
- <div>
393
- <p className="text-muted-foreground text-xs">
394
- {missedSchedule ? "Schedule" : "Metric"}
395
- </p>
396
- <p className="text-sm">
397
- {missedSchedule ? detail.rule?.schedule : detail.metric}
398
- </p>
399
- </div>
400
- <div>
401
- <p className="text-muted-foreground text-xs">
402
- {missedSchedule ? "Expected" : "Measured"}
403
- </p>
404
- <p className="text-sm font-semibold">
405
- {missedSchedule && detail.rule?.expected
406
- ? when(detail.rule.expected)
407
- : measurement(detail.measurement?.value, detail.unit)}
408
- </p>
409
- </div>
410
- <div>
411
- <p className="text-muted-foreground text-xs">
412
- {missedSchedule
413
- ? "Last run"
414
- : detail.kind === "anomaly"
415
- ? "Baseline"
416
- : "Limit"}
417
- </p>
418
- <p className="text-sm">
419
- {missedSchedule && detail.rule?.last_run
420
- ? when(detail.rule.last_run)
421
- : detail.kind === "anomaly"
422
- ? measurement(detail.baseline?.mean, detail.unit)
423
- : measurement(detail.measurement?.limit, detail.unit)}
424
- </p>
425
- </div>
426
- </div>
427
- <p className="text-muted-foreground text-xs">
428
- {detail.telemetry_type} group {detail.telemetry_group_hash} ·{" "}
429
- {detail.window?.minutes} minute window
430
- {detail.baseline?.sigmas != null &&
431
- ` · ${detail.baseline.sigmas}σ above a ${detail.baseline.days}-day baseline`}
432
- </p>
433
- </CardContent>
434
- </Card>
435
- {trend.length > 0 && (
436
- <Card>
437
- <CardHeader>
438
- <CardTitle>{detail.metric} trend (30d)</CardTitle>
439
- </CardHeader>
440
- <CardContent>
441
- <ChartContainer config={detectionConfig} className="h-48 w-full">
442
- <BarChart data={trend}>
443
- <CartesianGrid vertical={false} />
444
- <XAxis
445
- dataKey="day"
446
- tickLine={false}
447
- axisLine={false}
448
- minTickGap={24}
449
- tickFormatter={(value: string) =>
450
- new Date(value).toLocaleDateString(undefined, {
451
- month: "short",
452
- day: "numeric",
453
- })
454
- }
455
- />
456
- <YAxis width={45} tickLine={false} axisLine={false} />
457
- <ChartTooltip content={<ChartTooltipContent />} />
458
- <Bar dataKey="value" fill="var(--color-value)" radius={2} />
459
- </BarChart>
460
- </ChartContainer>
461
- </CardContent>
462
- </Card>
463
- )}
464
- <Card>
465
- <CardHeader>
466
- <CardTitle>Representative telemetry</CardTitle>
467
- </CardHeader>
468
- <CardContent>
469
- <DataTable
470
- rows={records}
471
- rowKey={(record) => `${record.record_type}-${record.record_id}`}
472
- empty="Representative raw telemetry has been pruned."
473
- onRowClick={(record) => {
474
- const path = detectionPath(issue, record.drilldown)
475
- if (path) router.visit(path)
476
- }}
477
- columns={[
478
- {
479
- key: "when",
480
- header: "When",
481
- cell: (record) => (
482
- <span className="text-xs">{when(record.occurred_at)}</span>
483
- ),
484
- },
485
- {
486
- key: "record",
487
- header: "Record",
488
- className: "max-w-0",
489
- cell: (record) => (
490
- <span className="block truncate font-mono text-xs">
491
- {record.name}
492
- </span>
493
- ),
494
- },
495
- {
496
- key: "duration",
497
- header: "Duration",
498
- align: "right",
499
- cell: (record) => `${record.duration_ms.toLocaleString()} ms`,
500
- },
501
- {
502
- key: "deploy",
503
- header: "Deploy",
504
- cell: (record) => (
505
- <span className="font-mono text-xs">
506
- {record.deploy?.slice(0, 10)}
507
- </span>
508
- ),
509
- },
510
- ]}
511
- />
512
- </CardContent>
513
- </Card>
514
- {deploys.length > 0 && (
515
- <Card>
516
- <CardHeader>
517
- <CardTitle>Deploy comparison (30d)</CardTitle>
518
- </CardHeader>
519
- <CardContent>
520
- <DataTable
521
- rows={deploys}
522
- rowKey={(deploy) => deploy.deploy}
523
- columns={[
524
- {
525
- key: "deploy",
526
- header: "Deploy",
527
- cell: (deploy) => (
528
- <span className="font-mono text-xs">
529
- {deploy.deploy.slice(0, 12)}
530
- </span>
531
- ),
532
- },
533
- {
534
- key: "events",
535
- header: "Events",
536
- align: "right",
537
- cell: (deploy) => count(deploy.events),
538
- },
539
- {
540
- key: "avg",
541
- header: "Avg",
542
- align: "right",
543
- cell: (deploy) => `${deploy.avg_ms} ms`,
544
- },
545
- {
546
- key: "max",
547
- header: "Max",
548
- align: "right",
549
- cell: (deploy) => `${deploy.max_ms} ms`,
550
- },
551
- ]}
552
- />
553
- </CardContent>
554
- </Card>
555
- )}
556
- </>
557
- )
558
- }
559
-
560
- export default function IssueShow(p: Props) {
561
- const i = p.issue
562
- const act = (action_name: string, extra: Record<string, unknown> = {}) =>
563
- router.patch(
564
- R.issuePath(i.id),
565
- { action_name, ...extra },
566
- { preserveScroll: true },
567
- )
568
- const [, copyAi] = useClipboard()
569
- const [aiCopied, setAiCopied] = useState(false)
570
- const [mergeOpen, setMergeOpen] = useState(false)
571
- const [mergeQuery, setMergeQuery] = useState("")
572
- const [mergeCandidates, setMergeCandidates] = useState<
573
- { id: number; key: string; title: string }[]
574
- >([])
575
- const [mergeLoading, setMergeLoading] = useState(false)
576
- const [mergeError, setMergeError] = useState(false)
577
- const membersById = new Map(p.members.map((m) => [m.id, m.name]))
578
- const getInitials = useInitials()
579
- const occurrencePath = (occurrence: Occurrence) =>
580
- executionPath({
581
- applicationId: i.application.id,
582
- environmentId: i.environment.id,
583
- source: occurrence.execution_source,
584
- executionId: occurrence.execution_id,
585
- })
586
- const latestPath = p.latest
587
- ? executionPath({
588
- applicationId: i.application.id,
589
- environmentId: i.environment.id,
590
- source: p.latest.execution_source,
591
- executionId: p.latest.execution_id,
592
- })
593
- : null
594
-
595
- useEffect(() => {
596
- if (!mergeOpen) return
597
-
598
- const controller = new AbortController()
599
- const timeout = window.setTimeout(() => {
600
- setMergeLoading(true)
601
- setMergeError(false)
602
- fetch(R.mergeCandidatesIssuePath(i.id, { q: mergeQuery }), {
603
- headers: { Accept: "application/json" },
604
- credentials: "same-origin",
605
- signal: controller.signal,
606
- })
607
- .then((response) => {
608
- if (!response.ok) throw new Error(`HTTP ${response.status}`)
609
- return response.json() as Promise<{
610
- issues: { id: number; key: string; title: string }[]
611
- }>
612
- })
613
- .then(({ issues }) => setMergeCandidates(issues))
614
- .catch(() => {
615
- if (controller.signal.aborted) return
616
- setMergeCandidates([])
617
- setMergeError(true)
618
- })
619
- .finally(() => {
620
- if (!controller.signal.aborted) setMergeLoading(false)
621
- })
622
- }, 150)
623
-
624
- return () => {
625
- window.clearTimeout(timeout)
626
- controller.abort()
627
- }
628
- }, [i.id, mergeOpen, mergeQuery])
629
-
630
- return (
631
- <AppLayout
632
- breadcrumbs={[
633
- { title: "Issues", href: R.issuesPath() },
634
- { title: i.key, href: R.issuePath(i.id) },
635
- ]}
636
- >
637
- <Head title={`${i.key} ${i.title}`} />
638
- <div className="flex flex-1 flex-col gap-4 p-3 md:gap-5 md:px-6 md:pt-2 md:pb-6">
639
- <div className="flex flex-wrap items-start justify-between gap-4">
640
- <div className="min-w-0">
641
- <div className="flex flex-wrap items-center gap-2">
642
- <span className="font-mono text-sm font-semibold">{i.key}</span>
643
- <IssueStatusBadge status={i.status} />
644
- <Badge variant="outline">{i.kind}</Badge>
645
- {i.source === "browser" && (
646
- <Badge variant="outline" className="gap-1">
647
- <Globe className="size-3" />
648
- browser
649
- </Badge>
650
- )}
651
- <span className="text-muted-foreground text-xs">
652
- {i.application.name} · {i.environment.name}
653
- </span>
654
- </div>
655
- <h1 className="mt-1 text-lg font-semibold break-words">
656
- {i.title}
657
- </h1>
658
- {i.culprit && (
659
- <div className="text-muted-foreground">
660
- <SourceLink
661
- location={i.culprit}
662
- deploy={i.sample.deploy as string | null}
663
- />
664
- </div>
665
- )}
666
- </div>
667
- <div className="flex flex-wrap items-center gap-2">
668
- <Select
669
- value={i.priority}
670
- onValueChange={(v) => act("priority", { priority: v })}
671
- >
672
- <SelectTrigger className="w-28">
673
- <SelectValue />
674
- </SelectTrigger>
675
- <SelectContent>
676
- {["low", "normal", "high", "urgent"].map((x) => (
677
- <SelectItem key={x} value={x}>
678
- {x}
679
- </SelectItem>
680
- ))}
681
- </SelectContent>
682
- </Select>
683
- <Select
684
- value={i.assignee ? String(i.assignee.id) : "none"}
685
- onValueChange={(v) =>
686
- act("assign", { assignee_id: v === "none" ? null : v })
687
- }
688
- >
689
- <SelectTrigger className="w-40">
690
- <SelectValue placeholder="Assign" />
691
- </SelectTrigger>
692
- <SelectContent>
693
- <SelectItem value="none">Unassigned</SelectItem>
694
- {p.members.map((m) => (
695
- <SelectItem key={m.id} value={String(m.id)}>
696
- {m.name}
697
- </SelectItem>
698
- ))}
699
- </SelectContent>
700
- </Select>
701
- {i.status === "merged" ? (
702
- <Button
703
- size="sm"
704
- variant="outline"
705
- onClick={() => act("unmerge")}
706
- >
707
- Unmerge
708
- </Button>
709
- ) : i.status === "open" ? (
710
- <>
711
- <Button size="sm" onClick={() => act("resolve")}>
712
- Resolve
713
- </Button>
714
- <Button
715
- size="sm"
716
- variant="outline"
717
- onClick={() => act("ignore")}
718
- >
719
- Ignore
720
- </Button>
721
- </>
722
- ) : (
723
- <Button size="sm" variant="outline" onClick={() => act("reopen")}>
724
- Reopen
725
- </Button>
726
- )}
727
- {i.status !== "merged" && (
728
- <Button
729
- size="sm"
730
- variant="outline"
731
- onClick={() => setMergeOpen(true)}
732
- >
733
- <GitMerge data-icon="inline-start" />
734
- Merge into…
735
- </Button>
736
- )}
737
- <Button
738
- size="sm"
739
- variant="outline"
740
- onClick={() => {
741
- void (async () => {
742
- const ok = await copyAi(copyForAiText(p))
743
- if (ok) {
744
- setAiCopied(true)
745
- setTimeout(() => setAiCopied(false), 1200)
746
- }
747
- })()
748
- }}
749
- >
750
- {aiCopied ? (
751
- <Check className="size-3.5" />
752
- ) : (
753
- <ClipboardCopy className="size-3.5" />
754
- )}
755
- Copy for AI
756
- </Button>
757
- </div>
758
- </div>
759
- {p.merged_into && (
760
- <div className="bg-muted flex flex-wrap items-center justify-between gap-2 rounded-md border px-4 py-3 text-sm">
761
- <span>
762
- Merged into{" "}
763
- <Link
764
- href={R.issuePath(p.merged_into.id)}
765
- className="font-mono font-semibold underline"
766
- >
767
- {p.merged_into.key}
768
- </Link>{" "}
769
- <span className="text-muted-foreground">
770
- {p.merged_into.title}
771
- </span>
772
- </span>
773
- <Button size="sm" variant="outline" onClick={() => act("unmerge")}>
774
- Unmerge
775
- </Button>
776
- </div>
777
- )}
778
- <CommandDialog
779
- open={mergeOpen}
780
- onOpenChange={(open) => {
781
- setMergeOpen(open)
782
- if (!open) {
783
- setMergeQuery("")
784
- setMergeCandidates([])
785
- setMergeError(false)
786
- }
787
- }}
788
- title="Merge into…"
789
- description="Search issues to merge this one into"
790
- >
791
- <CommandInput
792
- placeholder="Search issues by key or title…"
793
- value={mergeQuery}
794
- onValueChange={setMergeQuery}
795
- />
796
- <CommandList>
797
- <CommandEmpty>
798
- {mergeLoading
799
- ? "Searching…"
800
- : mergeError
801
- ? "Search failed. Try again."
802
- : "No open issues found."}
803
- </CommandEmpty>
804
- {mergeCandidates.length > 0 && (
805
- <CommandGroup>
806
- {mergeCandidates.map((m) => (
807
- <CommandItem
808
- key={m.id}
809
- value={`${m.key} ${m.title}`}
810
- onSelect={() => {
811
- act("merge", { target_id: m.id })
812
- setMergeOpen(false)
813
- }}
814
- >
815
- <span className="font-mono font-semibold">{m.key}</span>
816
- <span className="text-muted-foreground truncate">
817
- {m.title}
818
- </span>
819
- </CommandItem>
820
- ))}
821
- </CommandGroup>
822
- )}
823
- </CommandList>
824
- </CommandDialog>
825
- <StatStrip>
826
- <Stat
827
- label={p.detection?.count_label ?? "Events"}
828
- value={count(p.detection?.breached_windows ?? i.occurrences)}
829
- />
830
- <Stat
831
- label={p.detection ? "Events in measured window" : "Users affected"}
832
- value={
833
- p.detection
834
- ? p.detection.event_count == null
835
- ? "–"
836
- : count(p.detection.event_count)
837
- : i.affected_users
838
- }
839
- />
840
- <Stat
841
- label="First seen"
842
- value={ago(i.first_seen_at)}
843
- hint={when(i.first_seen_at)}
844
- />
845
- <Stat
846
- label="Last seen"
847
- value={ago(i.last_seen_at)}
848
- hint={when(i.last_seen_at)}
849
- />
850
- <Stat
851
- label={i.regressed_at ? "Regressed" : "Resolved"}
852
- value={
853
- i.regressed_at
854
- ? ago(i.regressed_at)
855
- : i.resolved_at
856
- ? ago(i.resolved_at)
857
- : "–"
858
- }
859
- hint={
860
- i.resolved_in_deploy
861
- ? `in ${i.resolved_in_deploy.slice(0, 12)}`
862
- : undefined
863
- }
864
- tone={i.regressed_at ? "destructive" : undefined}
865
- />
866
- </StatStrip>
867
- <div className="grid gap-6 lg:grid-cols-3">
868
- <div className="space-y-6 lg:col-span-2">
869
- {p.detection ? (
870
- <DetectionPanel issue={i} detail={p.detection} />
871
- ) : p.latest ? (
872
- <Card>
873
- <CardHeader className="flex-row items-center justify-between">
874
- <CardTitle>Latest occurrence</CardTitle>
875
- {latestPath && (
876
- <Link className="text-xs underline" href={latestPath}>
877
- Open {p.latest.execution_preview}
878
- </Link>
879
- )}
880
- </CardHeader>
881
- <CardContent>
882
- <ExceptionCard
883
- exception={p.latest}
884
- deploy={p.latest.deploy}
885
- />
886
- <div className="mt-4 space-y-4">
887
- <Breadcrumbs entries={p.breadcrumbs} />
888
- <BrowserBreadcrumbs crumbs={p.browser_breadcrumbs} />
889
- </div>
890
- {(p.latest.ruby_version ?? p.latest.rails_version) && (
891
- <p className="text-muted-foreground mt-3 text-xs">
892
- Ruby {p.latest.ruby_version} · Rails{" "}
893
- {p.latest.rails_version}
894
- </p>
895
- )}
896
- </CardContent>
897
- </Card>
898
- ) : (
899
- <Card>
900
- <CardContent>
901
- <JsonViewer data={i.sample} />
902
- </CardContent>
903
- </Card>
904
- )}
905
- <Card className={p.detection ? "hidden" : undefined}>
906
- <CardHeader>
907
- <CardTitle>Occurrences (30d)</CardTitle>
908
- </CardHeader>
909
- <CardContent>
910
- <DataTable
911
- rows={p.occurrences}
912
- rowKey={(o) => o.id}
913
- empty="Raw occurrences have been pruned."
914
- onRowClick={(o) => {
915
- const path = occurrencePath(o)
916
- if (path) router.visit(path)
917
- }}
918
- columns={[
919
- {
920
- key: "when",
921
- header: "When",
922
- cell: (o) => (
923
- <span className="text-xs">{when(o.occurred_at)}</span>
924
- ),
925
- },
926
- {
927
- key: "in",
928
- header: "In",
929
- cell: (o) => (
930
- <span className="font-mono text-xs">
931
- {o.execution_source} {o.execution_preview}
932
- </span>
933
- ),
934
- },
935
- {
936
- key: "u",
937
- header: "User",
938
- cell: (o) => (
939
- <span className="font-mono text-xs">
940
- {o.user_ref ?? ""}
941
- </span>
942
- ),
943
- },
944
- {
945
- key: "t",
946
- header: "Tenant",
947
- cell: (o) => (
948
- <span className="font-mono text-xs">
949
- {o.tenant ?? ""}
950
- </span>
951
- ),
952
- },
953
- {
954
- key: "d",
955
- header: "Deploy",
956
- cell: (o) => (
957
- <span className="font-mono text-xs">
958
- {o.deploy?.slice(0, 10)}
959
- </span>
960
- ),
961
- },
962
- {
963
- key: "h",
964
- header: "",
965
- cell: (o) =>
966
- o.handled ? (
967
- <Badge variant="secondary">handled</Badge>
968
- ) : null,
969
- },
970
- ]}
971
- />
972
- </CardContent>
973
- </Card>
974
- {p.attachments.length > 0 && (
975
- <Card>
976
- <CardHeader>
977
- <CardTitle>Attachments</CardTitle>
978
- </CardHeader>
979
- <CardContent>
980
- <DataTable
981
- rows={p.attachments}
982
- rowKey={(f) => f.id}
983
- columns={[
984
- {
985
- key: "name",
986
- header: "Name",
987
- className: "max-w-0",
988
- cell: (f) => (
989
- <span className="flex items-center gap-1.5">
990
- <span className="truncate font-mono text-xs">
991
- {f.name}
992
- </span>
993
- {f.truncated && (
994
- <Badge variant="secondary">truncated</Badge>
995
- )}
996
- </span>
997
- ),
998
- },
999
- {
1000
- key: "in",
1001
- header: "In",
1002
- cell: (f) => (
1003
- <span className="text-muted-foreground font-mono text-xs">
1004
- {f.execution_preview ?? "–"}
1005
- </span>
1006
- ),
1007
- },
1008
- {
1009
- key: "size",
1010
- header: "Size",
1011
- align: "right",
1012
- cell: (f) => bytes(f.bytes),
1013
- },
1014
- {
1015
- key: "links",
1016
- header: "",
1017
- align: "right",
1018
- cell: (f) => (
1019
- <span className="flex justify-end gap-3">
1020
- {f.viewable && (
1021
- <a
1022
- className="text-primary hover:underline"
1023
- href={R.applicationEnvironmentAttachmentPath(
1024
- i.application.id,
1025
- i.environment.id,
1026
- f.id,
1027
- { view: 1 },
1028
- )}
1029
- >
1030
- View
1031
- </a>
1032
- )}
1033
- <a
1034
- className="text-primary hover:underline"
1035
- href={R.applicationEnvironmentAttachmentPath(
1036
- i.application.id,
1037
- i.environment.id,
1038
- f.id,
1039
- )}
1040
- >
1041
- Download
1042
- </a>
1043
- </span>
1044
- ),
1045
- },
1046
- ]}
1047
- />
1048
- </CardContent>
1049
- </Card>
1050
- )}
1051
- </div>
1052
- <div className="space-y-6">
1053
- <Card className={p.detection ? "hidden" : undefined}>
1054
- <CardHeader>
1055
- <CardTitle>Daily (30d)</CardTitle>
1056
- </CardHeader>
1057
- <CardContent>
1058
- <ChartContainer config={dailyConfig} className="h-40 w-full">
1059
- <BarChart data={p.daily}>
1060
- <CartesianGrid vertical={false} />
1061
- <XAxis
1062
- dataKey="day"
1063
- tickLine={false}
1064
- axisLine={false}
1065
- minTickGap={24}
1066
- tickFormatter={(v: string) =>
1067
- new Date(v).toLocaleDateString(undefined, {
1068
- month: "short",
1069
- day: "numeric",
1070
- })
1071
- }
1072
- />
1073
- <YAxis
1074
- width={28}
1075
- tickLine={false}
1076
- axisLine={false}
1077
- allowDecimals={false}
1078
- />
1079
- <ChartTooltip
1080
- content={
1081
- <ChartTooltipContent
1082
- labelFormatter={(v) => {
1083
- const refs = deploysOn(p.deploys, String(v))
1084
- return refs.length > 0
1085
- ? `${String(v)} · deployed ${refs.join(", ")}`
1086
- : String(v)
1087
- }}
1088
- />
1089
- }
1090
- />
1091
- <Bar dataKey="count" fill="var(--color-count)" radius={2} />
1092
- {[...new Set(p.deploys.map((d) => d.at.slice(0, 10)))].map(
1093
- (day) => (
1094
- <ReferenceLine
1095
- key={day}
1096
- x={day}
1097
- stroke="var(--muted-foreground)"
1098
- strokeDasharray="3 3"
1099
- />
1100
- ),
1101
- )}
1102
- </BarChart>
1103
- </ChartContainer>
1104
- </CardContent>
1105
- </Card>
1106
- <Card className={p.detection ? "hidden" : undefined}>
1107
- <CardHeader>
1108
- <CardTitle>Grouping</CardTitle>
1109
- </CardHeader>
1110
- <CardContent className="space-y-3">
1111
- {p.fingerprint.length > 0 && (
1112
- <div className="flex flex-wrap gap-1">
1113
- {p.fingerprint.map((part, index) => (
1114
- <span
1115
- key={`${index}-${part}`}
1116
- className="bg-muted rounded px-1.5 py-0.5 font-mono text-[11px] break-all"
1117
- >
1118
- {part}
1119
- </span>
1120
- ))}
1121
- </div>
1122
- )}
1123
- <p className="text-muted-foreground text-xs">
1124
- {groupingHints[p.fingerprint_source ?? "default"] ??
1125
- `Fingerprint from ${p.fingerprint_source}.`}
1126
- </p>
1127
- {p.distinct_messages > 1 && (
1128
- <>
1129
- <div className="space-y-1">
1130
- {p.top_messages.map((m) => (
1131
- <div
1132
- key={m.message}
1133
- className="flex items-start justify-between gap-2 text-xs"
1134
- >
1135
- <span className="line-clamp-1 font-mono">
1136
- {m.message}
1137
- </span>
1138
- <span className="text-muted-foreground">
1139
- {count(m.count)}
1140
- </span>
1141
- </div>
1142
- ))}
1143
- </div>
1144
- <Button
1145
- size="sm"
1146
- variant="outline"
1147
- onClick={() => act("split", { by: "message" })}
1148
- >
1149
- <Split className="size-3.5" />
1150
- Split into {p.distinct_messages} issues
1151
- </Button>
1152
- </>
1153
- )}
1154
- </CardContent>
1155
- </Card>
1156
- {p.related_issues.length > 0 && (
1157
- <Card>
1158
- <CardHeader>
1159
- <CardTitle>Related issues</CardTitle>
1160
- </CardHeader>
1161
- <CardContent className="space-y-2">
1162
- {p.related_issues.map((r) => (
1163
- <Link
1164
- key={r.id}
1165
- href={R.issuePath(r.id)}
1166
- className="hover:bg-muted/60 -mx-2 flex flex-col rounded px-2 py-1 text-xs"
1167
- >
1168
- <span className="flex items-center gap-2">
1169
- <span className="font-mono font-semibold">{r.key}</span>
1170
- <IssueStatusBadge status={r.status} />
1171
- </span>
1172
- <span className="text-muted-foreground line-clamp-1">
1173
- {r.title}
1174
- </span>
1175
- <span className="text-muted-foreground">
1176
- {r.environment.name}
1177
- </span>
1178
- </Link>
1179
- ))}
1180
- </CardContent>
1181
- </Card>
1182
- )}
1183
- {p.merged_issues.length > 0 && (
1184
- <Card>
1185
- <CardHeader>
1186
- <CardTitle>Merged issues</CardTitle>
1187
- </CardHeader>
1188
- <CardContent className="space-y-2">
1189
- {p.merged_issues.map((m) => (
1190
- <Link
1191
- key={m.id}
1192
- href={R.issuePath(m.id)}
1193
- className="hover:bg-muted/60 -mx-2 flex flex-col rounded px-2 py-1 text-xs"
1194
- >
1195
- <span className="font-mono font-semibold">{m.key}</span>
1196
- <span className="text-muted-foreground line-clamp-1">
1197
- {m.title}
1198
- </span>
1199
- </Link>
1200
- ))}
1201
- </CardContent>
1202
- </Card>
1203
- )}
1204
- {p.alerts.length > 0 && (
1205
- <Card>
1206
- <CardHeader>
1207
- <CardTitle>Alerts</CardTitle>
1208
- </CardHeader>
1209
- <CardContent className="space-y-2">
1210
- {p.alerts.map((al) => (
1211
- <div
1212
- key={al.id}
1213
- className="flex flex-col gap-0.5 text-xs"
1214
- title={al.error ?? undefined}
1215
- >
1216
- <div className="flex items-center gap-2">
1217
- <Badge
1218
- variant={
1219
- al.status === "failed" ? "destructive" : "outline"
1220
- }
1221
- className="capitalize"
1222
- >
1223
- {al.status}
1224
- </Badge>
1225
- <span>
1226
- {al.event} via {al.integration.name}
1227
- </span>
1228
- <RelativeTime
1229
- iso={al.sent_at}
1230
- className="text-muted-foreground"
1231
- />
1232
- </div>
1233
- {al.error && (
1234
- <span className="text-destructive">{al.error}</span>
1235
- )}
1236
- </div>
1237
- ))}
1238
- </CardContent>
1239
- </Card>
1240
- )}
1241
- <Card>
1242
- <CardHeader>
1243
- <CardTitle>By deploy</CardTitle>
1244
- </CardHeader>
1245
- <CardContent>
1246
- <DataTable
1247
- rows={Object.entries(p.by_deploy)}
1248
- rowKey={([d]) => d ?? "none"}
1249
- columns={[
1250
- {
1251
- key: "d",
1252
- header: "Deploy",
1253
- cell: ([d]) => (
1254
- <span className="font-mono text-xs">
1255
- {d ?? "unknown"}
1256
- </span>
1257
- ),
1258
- },
1259
- {
1260
- key: "n",
1261
- header: "Count",
1262
- align: "right",
1263
- cell: ([, n]) => n,
1264
- },
1265
- ]}
1266
- />
1267
- </CardContent>
1268
- </Card>
1269
- {Object.keys(p.by_tenant).length > 0 && (
1270
- <Card>
1271
- <CardHeader>
1272
- <CardTitle>By tenant</CardTitle>
1273
- </CardHeader>
1274
- <CardContent>
1275
- <DataTable
1276
- rows={Object.entries(p.by_tenant)}
1277
- rowKey={([t]) => t}
1278
- columns={[
1279
- {
1280
- key: "t",
1281
- header: "Tenant",
1282
- cell: ([t]) => (
1283
- <span className="font-mono text-xs">{t}</span>
1284
- ),
1285
- },
1286
- {
1287
- key: "n",
1288
- header: "Count",
1289
- align: "right",
1290
- cell: ([, n]) => n,
1291
- },
1292
- ]}
1293
- />
1294
- </CardContent>
1295
- </Card>
1296
- )}
1297
- <Card>
1298
- <CardHeader>
1299
- <CardTitle>Activity</CardTitle>
1300
- </CardHeader>
1301
- <CardContent className="space-y-3">
1302
- {p.activities.map((a) => {
1303
- const fromLinear = a.data.source === "linear"
1304
- const linearAuthor =
1305
- typeof a.data.author_name === "string"
1306
- ? a.data.author_name
1307
- : "Linear"
1308
- const actorName =
1309
- a.user?.name ?? (fromLinear ? linearAuthor : "System")
1310
- return (
1311
- <div key={a.id} className="flex gap-2 text-sm">
1312
- <Avatar className="size-6">
1313
- <AvatarFallback className="text-[10px]">
1314
- {a.user || fromLinear
1315
- ? getInitials(actorName)
1316
- : "sys"}
1317
- </AvatarFallback>
1318
- </Avatar>
1319
- <div className="min-w-0 flex-1">
1320
- <div className="text-xs">
1321
- <span className="font-medium">{actorName}</span>{" "}
1322
- <span className="text-muted-foreground">
1323
- {activitySentence(a, membersById)}
1324
- </span>{" "}
1325
- <RelativeTime
1326
- iso={a.created_at}
1327
- className="text-muted-foreground"
1328
- />
1329
- </div>
1330
- {a.kind === "comment" && (
1331
- <div className="mt-0.5 whitespace-pre-wrap">
1332
- {String(a.data.body)}
1333
- </div>
1334
- )}
1335
- </div>
1336
- </div>
1337
- )
1338
- })}
1339
- <Form
1340
- method="post"
1341
- action={R.issueCommentsPath(i.id)}
1342
- options={{ preserveScroll: true }}
1343
- resetOnSuccess
1344
- className="space-y-2"
1345
- >
1346
- {({ processing }) => (
1347
- <>
1348
- <Textarea
1349
- name="body"
1350
- placeholder="Add a comment…"
1351
- required
1352
- rows={3}
1353
- />
1354
- <Button size="sm" disabled={processing}>
1355
- Comment
1356
- </Button>
1357
- </>
1358
- )}
1359
- </Form>
1360
- </CardContent>
1361
- </Card>
1362
- </div>
1363
- </div>
1364
- </div>
1365
- </AppLayout>
1366
- )
1367
- }