railwatch 0.9.4 → 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 (452) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +37 -33
  3. data/CHANGELOG.md +50 -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 +35 -66
  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/configuration.rb +21 -151
  21. data/lib/railwatch/engine.rb +9 -118
  22. data/lib/railwatch/patches.rb +0 -11
  23. data/lib/railwatch/reporter.rb +3 -19
  24. data/lib/railwatch/subscribers/process_info.rb +1 -2
  25. data/lib/railwatch/version.rb +1 -1
  26. data/lib/railwatch.rb +4 -62
  27. data/lib/tasks/railwatch_tasks.rake +3 -325
  28. data/llms.txt +23 -25
  29. metadata +3 -453
  30. data/app/channels/railwatch/environment_channel.rb +0 -62
  31. data/app/controllers/concerns/railwatch/telemetry_identity.rb +0 -25
  32. data/app/controllers/railwatch/alerts_controller.rb +0 -48
  33. data/app/controllers/railwatch/anomaly_rules_controller.rb +0 -31
  34. data/app/controllers/railwatch/attachments_controller.rb +0 -22
  35. data/app/controllers/railwatch/broadcasts_controller.rb +0 -15
  36. data/app/controllers/railwatch/cache_events_controller.rb +0 -30
  37. data/app/controllers/railwatch/commands_controller.rb +0 -16
  38. data/app/controllers/railwatch/comments_controller.rb +0 -11
  39. data/app/controllers/railwatch/dashboard_controller.rb +0 -70
  40. data/app/controllers/railwatch/deploys_controller.rb +0 -34
  41. data/app/controllers/railwatch/deprecations_controller.rb +0 -54
  42. data/app/controllers/railwatch/environment_scoped.rb +0 -81
  43. data/app/controllers/railwatch/exceptions_controller.rb +0 -52
  44. data/app/controllers/railwatch/executions_controller.rb +0 -13
  45. data/app/controllers/railwatch/issues_controller.rb +0 -258
  46. data/app/controllers/railwatch/jobs_controller.rb +0 -63
  47. data/app/controllers/railwatch/llm_calls_controller.rb +0 -124
  48. data/app/controllers/railwatch/logs_controller.rb +0 -40
  49. data/app/controllers/railwatch/mails_controller.rb +0 -19
  50. data/app/controllers/railwatch/notifications_controller.rb +0 -11
  51. data/app/controllers/railwatch/outgoing_requests_controller.rb +0 -19
  52. data/app/controllers/railwatch/overview_controller.rb +0 -38
  53. data/app/controllers/railwatch/people_controller.rb +0 -25
  54. data/app/controllers/railwatch/processes_controller.rb +0 -33
  55. data/app/controllers/railwatch/profiles_controller.rb +0 -65
  56. data/app/controllers/railwatch/queries_controller.rb +0 -59
  57. data/app/controllers/railwatch/releases_controller.rb +0 -75
  58. data/app/controllers/railwatch/requests_controller.rb +0 -37
  59. data/app/controllers/railwatch/saved_views_controller.rb +0 -53
  60. data/app/controllers/railwatch/scheduled_tasks_controller.rb +0 -45
  61. data/app/controllers/railwatch/spans_controller.rb +0 -47
  62. data/app/controllers/railwatch/storage_ops_controller.rb +0 -15
  63. data/app/controllers/railwatch/tenants_controller.rb +0 -44
  64. data/app/controllers/railwatch/thresholds_controller.rb +0 -47
  65. data/app/controllers/railwatch/traces_controller.rb +0 -72
  66. data/app/controllers/railwatch/transactions_controller.rb +0 -21
  67. data/app/controllers/railwatch/view_renders_controller.rb +0 -28
  68. data/app/controllers/railwatch/visits_controller.rb +0 -53
  69. data/app/frontend/components/app-content.tsx +0 -26
  70. data/app/frontend/components/app-logo-icon.tsx +0 -23
  71. data/app/frontend/components/app-logo.tsx +0 -14
  72. data/app/frontend/components/app-shell.tsx +0 -38
  73. data/app/frontend/components/app-sidebar-header.tsx +0 -54
  74. data/app/frontend/components/app-sidebar.tsx +0 -94
  75. data/app/frontend/components/app-wordmark.tsx +0 -29
  76. data/app/frontend/components/breadcrumbs.tsx +0 -46
  77. data/app/frontend/components/icon.tsx +0 -16
  78. data/app/frontend/components/input-error.tsx +0 -18
  79. data/app/frontend/components/nav-footer.tsx +0 -46
  80. data/app/frontend/components/nav-main.tsx +0 -41
  81. data/app/frontend/components/nav-user.tsx +0 -50
  82. data/app/frontend/components/railwatch/chart-hover.tsx +0 -34
  83. data/app/frontend/components/railwatch/chart-panel.tsx +0 -170
  84. data/app/frontend/components/railwatch/code.tsx +0 -28
  85. data/app/frontend/components/railwatch/command-palette.tsx +0 -132
  86. data/app/frontend/components/railwatch/copy-block.tsx +0 -52
  87. data/app/frontend/components/railwatch/copy-id.tsx +0 -49
  88. data/app/frontend/components/railwatch/cursor-load-more.tsx +0 -39
  89. data/app/frontend/components/railwatch/data-table.tsx +0 -195
  90. data/app/frontend/components/railwatch/digit-roll.tsx +0 -312
  91. data/app/frontend/components/railwatch/empty-state.tsx +0 -159
  92. data/app/frontend/components/railwatch/env-nav.tsx +0 -130
  93. data/app/frontend/components/railwatch/env-switcher.tsx +0 -145
  94. data/app/frontend/components/railwatch/filter-bar.tsx +0 -153
  95. data/app/frontend/components/railwatch/flamegraph.tsx +0 -324
  96. data/app/frontend/components/railwatch/frames.tsx +0 -295
  97. data/app/frontend/components/railwatch/json-viewer.tsx +0 -159
  98. data/app/frontend/components/railwatch/live-dot.tsx +0 -78
  99. data/app/frontend/components/railwatch/live-toggle.tsx +0 -76
  100. data/app/frontend/components/railwatch/metric-chart.tsx +0 -112
  101. data/app/frontend/components/railwatch/mobile-page-switcher.tsx +0 -89
  102. data/app/frontend/components/railwatch/onboarding.tsx +0 -91
  103. data/app/frontend/components/railwatch/origin-identity.tsx +0 -84
  104. data/app/frontend/components/railwatch/page-header.tsx +0 -42
  105. data/app/frontend/components/railwatch/percentile-picker.tsx +0 -18
  106. data/app/frontend/components/railwatch/relative-time.tsx +0 -18
  107. data/app/frontend/components/railwatch/saved-views.tsx +0 -232
  108. data/app/frontend/components/railwatch/segmented.tsx +0 -64
  109. data/app/frontend/components/railwatch/series-chart.tsx +0 -400
  110. data/app/frontend/components/railwatch/shortcuts-dialog.tsx +0 -89
  111. data/app/frontend/components/railwatch/signal-toast.tsx +0 -121
  112. data/app/frontend/components/railwatch/sort-header.tsx +0 -37
  113. data/app/frontend/components/railwatch/source-link.tsx +0 -57
  114. data/app/frontend/components/railwatch/sparkline-cell.tsx +0 -14
  115. data/app/frontend/components/railwatch/sql-block.tsx +0 -116
  116. data/app/frontend/components/railwatch/stat.tsx +0 -185
  117. data/app/frontend/components/railwatch/status-badge.tsx +0 -79
  118. data/app/frontend/components/railwatch/step-picker.tsx +0 -57
  119. data/app/frontend/components/railwatch/timeline.tsx +0 -405
  120. data/app/frontend/components/railwatch/utilisation-bar.tsx +0 -44
  121. data/app/frontend/components/railwatch/window-picker.tsx +0 -147
  122. data/app/frontend/components/ui/avatar.tsx +0 -51
  123. data/app/frontend/components/ui/badge.tsx +0 -46
  124. data/app/frontend/components/ui/breadcrumb.tsx +0 -109
  125. data/app/frontend/components/ui/button.tsx +0 -60
  126. data/app/frontend/components/ui/card.tsx +0 -92
  127. data/app/frontend/components/ui/chart.tsx +0 -374
  128. data/app/frontend/components/ui/checkbox.tsx +0 -32
  129. data/app/frontend/components/ui/collapsible.tsx +0 -31
  130. data/app/frontend/components/ui/command.tsx +0 -182
  131. data/app/frontend/components/ui/dialog.tsx +0 -158
  132. data/app/frontend/components/ui/dropdown-menu.tsx +0 -255
  133. data/app/frontend/components/ui/hover-card.tsx +0 -42
  134. data/app/frontend/components/ui/input.tsx +0 -21
  135. data/app/frontend/components/ui/label.tsx +0 -24
  136. data/app/frontend/components/ui/popover.tsx +0 -87
  137. data/app/frontend/components/ui/select.tsx +0 -185
  138. data/app/frontend/components/ui/separator.tsx +0 -28
  139. data/app/frontend/components/ui/sheet.tsx +0 -137
  140. data/app/frontend/components/ui/sidebar.tsx +0 -726
  141. data/app/frontend/components/ui/skeleton.tsx +0 -13
  142. data/app/frontend/components/ui/sonner.tsx +0 -41
  143. data/app/frontend/components/ui/table.tsx +0 -114
  144. data/app/frontend/components/ui/tabs.tsx +0 -91
  145. data/app/frontend/components/ui/textarea.tsx +0 -18
  146. data/app/frontend/components/ui/tooltip.tsx +0 -61
  147. data/app/frontend/components/user-info.tsx +0 -32
  148. data/app/frontend/components/user-menu-content.tsx +0 -94
  149. data/app/frontend/entrypoints/application.css +0 -244
  150. data/app/frontend/entrypoints/inertia.tsx +0 -92
  151. data/app/frontend/fonts/CommitMono-Bold.woff2 +0 -0
  152. data/app/frontend/fonts/CommitMono-Regular.woff2 +0 -0
  153. data/app/frontend/fonts/Roboto-Black.woff2 +0 -0
  154. data/app/frontend/fonts/Roboto-Bold.woff2 +0 -0
  155. data/app/frontend/fonts/Roboto-Medium.woff2 +0 -0
  156. data/app/frontend/fonts/Roboto-Regular.woff2 +0 -0
  157. data/app/frontend/hooks/use-appearance.tsx +0 -67
  158. data/app/frontend/hooks/use-clipboard.ts +0 -32
  159. data/app/frontend/hooks/use-flash.tsx +0 -23
  160. data/app/frontend/hooks/use-hotkeys.ts +0 -63
  161. data/app/frontend/hooks/use-initials.tsx +0 -15
  162. data/app/frontend/hooks/use-live.ts +0 -205
  163. data/app/frontend/hooks/use-mobile-navigation.ts +0 -8
  164. data/app/frontend/hooks/use-mobile.ts +0 -21
  165. data/app/frontend/hooks/use-percentile.ts +0 -24
  166. data/app/frontend/hooks/use-reduced-motion.ts +0 -25
  167. data/app/frontend/hooks/use-row-nav.ts +0 -106
  168. data/app/frontend/hooks/use-step.ts +0 -27
  169. data/app/frontend/hooks/use-ticker.ts +0 -12
  170. data/app/frontend/hooks/use-window.ts +0 -58
  171. data/app/frontend/layouts/app/app-sidebar-layout.tsx +0 -107
  172. data/app/frontend/layouts/app-layout.tsx +0 -21
  173. data/app/frontend/layouts/env-layout.tsx +0 -53
  174. data/app/frontend/layouts/persistent-layout.tsx +0 -18
  175. data/app/frontend/lib/cable.ts +0 -10
  176. data/app/frontend/lib/env-pages.ts +0 -236
  177. data/app/frontend/lib/execution-path.ts +0 -55
  178. data/app/frontend/lib/explain.ts +0 -50
  179. data/app/frontend/lib/filter.ts +0 -25
  180. data/app/frontend/lib/flamegraph.ts +0 -155
  181. data/app/frontend/lib/format.ts +0 -71
  182. data/app/frontend/lib/keyboard.ts +0 -18
  183. data/app/frontend/lib/mount.ts +0 -23
  184. data/app/frontend/lib/source-link.ts +0 -217
  185. data/app/frontend/lib/utils.ts +0 -6
  186. data/app/frontend/pages/alerts/index.tsx +0 -171
  187. data/app/frontend/pages/broadcasts/index.tsx +0 -171
  188. data/app/frontend/pages/cache_events/index.tsx +0 -127
  189. data/app/frontend/pages/commands/index.tsx +0 -143
  190. data/app/frontend/pages/deploys/index.tsx +0 -94
  191. data/app/frontend/pages/deploys/show.tsx +0 -309
  192. data/app/frontend/pages/deprecations/index.tsx +0 -176
  193. data/app/frontend/pages/exceptions/index.tsx +0 -271
  194. data/app/frontend/pages/executions/show.tsx +0 -821
  195. data/app/frontend/pages/issues/index.tsx +0 -399
  196. data/app/frontend/pages/issues/show.tsx +0 -1367
  197. data/app/frontend/pages/jobs/index.tsx +0 -381
  198. data/app/frontend/pages/jobs/klass.tsx +0 -215
  199. data/app/frontend/pages/llm_calls/index.tsx +0 -664
  200. data/app/frontend/pages/logs/index.tsx +0 -303
  201. data/app/frontend/pages/mails/index.tsx +0 -218
  202. data/app/frontend/pages/notifications/index.tsx +0 -161
  203. data/app/frontend/pages/outgoing_requests/index.tsx +0 -309
  204. data/app/frontend/pages/overview/show.tsx +0 -333
  205. data/app/frontend/pages/people/index.tsx +0 -102
  206. data/app/frontend/pages/people/show.tsx +0 -149
  207. data/app/frontend/pages/processes/index.tsx +0 -335
  208. data/app/frontend/pages/profiles/index.tsx +0 -110
  209. data/app/frontend/pages/profiles/show.tsx +0 -105
  210. data/app/frontend/pages/queries/index.tsx +0 -492
  211. data/app/frontend/pages/queries/show.tsx +0 -276
  212. data/app/frontend/pages/releases/index.tsx +0 -190
  213. data/app/frontend/pages/releases/release-health.tsx +0 -68
  214. data/app/frontend/pages/releases/release-path.ts +0 -25
  215. data/app/frontend/pages/releases/show.tsx +0 -262
  216. data/app/frontend/pages/requests/index.tsx +0 -235
  217. data/app/frontend/pages/requests/route.tsx +0 -159
  218. data/app/frontend/pages/scheduled_tasks/index.tsx +0 -220
  219. data/app/frontend/pages/spans/index.tsx +0 -229
  220. data/app/frontend/pages/spans/show.tsx +0 -220
  221. data/app/frontend/pages/storage_ops/index.tsx +0 -150
  222. data/app/frontend/pages/tenants/index.tsx +0 -236
  223. data/app/frontend/pages/tenants/show.tsx +0 -477
  224. data/app/frontend/pages/tenants/tenant-path.ts +0 -26
  225. data/app/frontend/pages/thresholds/index.tsx +0 -414
  226. data/app/frontend/pages/traces/show.tsx +0 -208
  227. data/app/frontend/pages/transactions/index.tsx +0 -220
  228. data/app/frontend/pages/view_renders/index.tsx +0 -241
  229. data/app/frontend/pages/visits/index.tsx +0 -321
  230. data/app/frontend/public/icon.png +0 -0
  231. data/app/frontend/public/icon.svg +0 -5
  232. data/app/frontend/routes/index.d.ts +0 -2136
  233. data/app/frontend/routes/index.js +0 -2112
  234. data/app/frontend/types/globals.d.ts +0 -9
  235. data/app/frontend/types/index.ts +0 -288
  236. data/app/frontend/types/vite-env.d.ts +0 -9
  237. data/app/helpers/railwatch/assets_helper.rb +0 -52
  238. data/app/jobs/railwatch/anomaly_scan_job.rb +0 -11
  239. data/app/jobs/railwatch/application_job.rb +0 -7
  240. data/app/jobs/railwatch/auto_resolve_issues_job.rb +0 -19
  241. data/app/jobs/railwatch/check_scheduled_tasks_job.rb +0 -113
  242. data/app/jobs/railwatch/detect_anomalies_job.rb +0 -171
  243. data/app/jobs/railwatch/detect_performance_issues_job.rb +0 -95
  244. data/app/jobs/railwatch/group_exceptions_job.rb +0 -91
  245. data/app/jobs/railwatch/optimize_telemetry_job.rb +0 -23
  246. data/app/jobs/railwatch/performance_scan_job.rb +0 -11
  247. data/app/jobs/railwatch/prune_telemetry_job.rb +0 -129
  248. data/app/jobs/railwatch/release_health_rollup_job.rb +0 -64
  249. data/app/jobs/railwatch/rollup_catchup_job.rb +0 -21
  250. data/app/jobs/railwatch/rollup_job.rb +0 -132
  251. data/app/jobs/railwatch/scheduled_task_scan_job.rb +0 -11
  252. data/app/models/concerns/railwatch/detection_snapshotting.rb +0 -20
  253. data/app/models/railwatch/alert.rb +0 -229
  254. data/app/models/railwatch/alert_rule.rb +0 -121
  255. data/app/models/railwatch/anomaly_rule.rb +0 -51
  256. data/app/models/railwatch/application.rb +0 -44
  257. data/app/models/railwatch/application_record.rb +0 -33
  258. data/app/models/railwatch/comment.rb +0 -36
  259. data/app/models/railwatch/deploy.rb +0 -67
  260. data/app/models/railwatch/environment.rb +0 -68
  261. data/app/models/railwatch/execution_presenter.rb +0 -185
  262. data/app/models/railwatch/filter_query.rb +0 -184
  263. data/app/models/railwatch/followup_receipt.rb +0 -27
  264. data/app/models/railwatch/ingest/batch.rb +0 -355
  265. data/app/models/railwatch/ingest/mapper.rb +0 -585
  266. data/app/models/railwatch/ingest/payload.rb +0 -96
  267. data/app/models/railwatch/ingest/rollup_absorber.rb +0 -170
  268. data/app/models/railwatch/ingest/writer.rb +0 -137
  269. data/app/models/railwatch/issue.rb +0 -277
  270. data/app/models/railwatch/issue_activity.rb +0 -23
  271. data/app/models/railwatch/issue_detection_presenter.rb +0 -309
  272. data/app/models/railwatch/issue_detection_snapshot.rb +0 -77
  273. data/app/models/railwatch/maintenance_task.rb +0 -53
  274. data/app/models/railwatch/saved_view.rb +0 -63
  275. data/app/models/railwatch/telemetry/aggregations.rb +0 -251
  276. data/app/models/railwatch/telemetry/attachment.rb +0 -31
  277. data/app/models/railwatch/telemetry/bounded_gzip.rb +0 -101
  278. data/app/models/railwatch/telemetry/broadcast.rb +0 -13
  279. data/app/models/railwatch/telemetry/cache_event.rb +0 -16
  280. data/app/models/railwatch/telemetry/child.rb +0 -53
  281. data/app/models/railwatch/telemetry/cursor_page.rb +0 -99
  282. data/app/models/railwatch/telemetry/deprecation.rb +0 -13
  283. data/app/models/railwatch/telemetry/enqueued_job.rb +0 -13
  284. data/app/models/railwatch/telemetry/exception.rb +0 -28
  285. data/app/models/railwatch/telemetry/execution.rb +0 -152
  286. data/app/models/railwatch/telemetry/export_delivery.rb +0 -32
  287. data/app/models/railwatch/telemetry/export_destination.rb +0 -66
  288. data/app/models/railwatch/telemetry/health_sample.rb +0 -72
  289. data/app/models/railwatch/telemetry/ingest_batch.rb +0 -31
  290. data/app/models/railwatch/telemetry/llm_call.rb +0 -37
  291. data/app/models/railwatch/telemetry/log.rb +0 -91
  292. data/app/models/railwatch/telemetry/mail.rb +0 -13
  293. data/app/models/railwatch/telemetry/n_plus_one.rb +0 -92
  294. data/app/models/railwatch/telemetry/notification.rb +0 -13
  295. data/app/models/railwatch/telemetry/outgoing_request.rb +0 -13
  296. data/app/models/railwatch/telemetry/person.rb +0 -50
  297. data/app/models/railwatch/telemetry/process.rb +0 -10
  298. data/app/models/railwatch/telemetry/profile.rb +0 -37
  299. data/app/models/railwatch/telemetry/query.rb +0 -57
  300. data/app/models/railwatch/telemetry/query_shape.rb +0 -45
  301. data/app/models/railwatch/telemetry/release_health.rb +0 -69
  302. data/app/models/railwatch/telemetry/rollup.rb +0 -157
  303. data/app/models/railwatch/telemetry/session.rb +0 -24
  304. data/app/models/railwatch/telemetry/span.rb +0 -19
  305. data/app/models/railwatch/telemetry/storage_op.rb +0 -13
  306. data/app/models/railwatch/telemetry/tenant.rb +0 -240
  307. data/app/models/railwatch/telemetry/transaction.rb +0 -13
  308. data/app/models/railwatch/telemetry/view_render.rb +0 -13
  309. data/app/models/railwatch/telemetry/visit.rb +0 -24
  310. data/app/models/railwatch/telemetry_record.rb +0 -107
  311. data/app/models/railwatch/threshold.rb +0 -66
  312. data/app/models/railwatch/user.rb +0 -47
  313. data/app/models/railwatch/viewer.rb +0 -13
  314. data/app/models/railwatch/window.rb +0 -66
  315. data/app/views/layouts/railwatch/dashboard.html.erb +0 -25
  316. data/db/railwatch_migrate/20260916000000_create_railwatch_tables.rb +0 -151
  317. data/db/railwatch_migrate/20260917000000_create_railwatch_maintenance_tasks.rb +0 -18
  318. data/db/railwatch_migrate/20260917120000_create_railwatch_followup_receipts.rb +0 -20
  319. data/db/railwatch_migrate/20260918120000_widen_host_user_ids.rb +0 -71
  320. data/db/railwatch_telemetry_migrate/20260903000000_enable_incremental_vacuum.rb +0 -62
  321. data/db/railwatch_telemetry_migrate/20260903000001_create_telemetry.rb +0 -481
  322. data/db/railwatch_telemetry_migrate/20260903000002_rename_tenant_to_app_tenant.rb +0 -14
  323. data/db/railwatch_telemetry_migrate/20260903000003_add_statement_count_to_transactions.rb +0 -9
  324. data/db/railwatch_telemetry_migrate/20260903000004_add_role_and_channel.rb +0 -10
  325. data/db/railwatch_telemetry_migrate/20260903000005_add_locals_to_exceptions.rb +0 -9
  326. data/db/railwatch_telemetry_migrate/20260903000006_add_spans_health_vitals_and_fts.rb +0 -82
  327. data/db/railwatch_telemetry_migrate/20260903000007_rename_span_attributes_to_payload.rb +0 -10
  328. data/db/railwatch_telemetry_migrate/20260903000008_add_profiles_and_attachments.rb +0 -60
  329. data/db/railwatch_telemetry_migrate/20260903000009_add_truncated_to_attachments.rb +0 -9
  330. data/db/railwatch_telemetry_migrate/20260903000010_create_sessions_and_release_health.rb +0 -51
  331. data/db/railwatch_telemetry_migrate/20260903000011_add_fingerprint_to_exceptions.rb +0 -11
  332. data/db/railwatch_telemetry_migrate/20260904000012_add_failed_to_broadcasts.rb +0 -10
  333. data/db/railwatch_telemetry_migrate/20260904010000_add_filter_cursor_indexes.rb +0 -37
  334. data/db/railwatch_telemetry_migrate/20260904020000_add_n_plus_ones_execution_id_index.rb +0 -11
  335. data/db/railwatch_telemetry_migrate/20260904120000_create_query_shapes.rb +0 -15
  336. data/db/railwatch_telemetry_migrate/20260906120000_add_backpressure_factor_to_ingest_batches.rb +0 -9
  337. data/db/railwatch_telemetry_migrate/20260907000000_rename_lantern_version_on_processes.rb +0 -10
  338. data/db/railwatch_telemetry_migrate/20260913000000_rename_nightrail_version_on_processes.rb +0 -9
  339. data/db/railwatch_telemetry_migrate/20260914000000_drop_orphan_durable_ingest_tables.rb +0 -18
  340. data/db/railwatch_telemetry_migrate/20260914010000_drop_orphan_durable_ingest_columns.rb +0 -25
  341. data/db/railwatch_telemetry_migrate/20260915000000_create_llm_calls.rb +0 -55
  342. data/db/railwatch_telemetry_migrate/20260915120000_add_detail_to_llm_calls.rb +0 -21
  343. data/db/railwatch_telemetry_migrate/20260915200000_add_explained_index_to_queries.rb +0 -16
  344. data/db/railwatch_telemetry_migrate/20260915210000_add_slowest_index_to_queries.rb +0 -18
  345. data/db/railwatch_telemetry_migrate/20260917010000_add_batch_ledger_to_ingest_batches.rb +0 -16
  346. data/db/railwatch_telemetry_migrate/20260919000100_create_export_queue.rb +0 -146
  347. data/db/railwatch_telemetry_migrate/20260922000000_add_covering_indexes_for_dashboard_aggregates.rb +0 -34
  348. data/db/railwatch_telemetry_migrate/20260923000000_add_people_index.rb +0 -15
  349. data/db/railwatch_telemetry_migrate/20260925000000_add_tenant_summary_index.rb +0 -17
  350. data/db/railwatch_telemetry_migrate/20261008000000_add_in_transaction_to_outgoing_requests.rb +0 -10
  351. data/docs/embedded.md +0 -503
  352. data/lib/puma/plugin/railwatch.rb +0 -215
  353. data/lib/railwatch/dashboard_assets.rb +0 -45
  354. data/lib/railwatch/embedded.rb +0 -50
  355. data/lib/railwatch/export/client.rb +0 -124
  356. data/lib/railwatch/export/lease.rb +0 -56
  357. data/lib/railwatch/export/outbox.rb +0 -361
  358. data/lib/railwatch/export/policy.rb +0 -79
  359. data/lib/railwatch/export/sender.rb +0 -130
  360. data/lib/railwatch/ingest_request_body_limit.rb +0 -10
  361. data/lib/railwatch/maintenance.rb +0 -194
  362. data/lib/railwatch/patches/migration_busy_timeout.rb +0 -73
  363. data/lib/railwatch/transport/local.rb +0 -78
  364. data/lib/railwatch/transport/socket.rb +0 -183
  365. data/lib/railwatch/writer.rb +0 -370
  366. data/public/railwatch/assets/CommitMono-Bold-D6h61ieg.woff2 +0 -0
  367. data/public/railwatch/assets/CommitMono-Regular-zr8w7Obm.woff2 +0 -0
  368. data/public/railwatch/assets/Roboto-Black-auA4GeOK.woff2 +0 -0
  369. data/public/railwatch/assets/Roboto-Bold-CJLnO8j1.woff2 +0 -0
  370. data/public/railwatch/assets/Roboto-Medium-Cm2bwKpj.woff2 +0 -0
  371. data/public/railwatch/assets/Roboto-Regular-Chaq1-PV.woff2 +0 -0
  372. data/public/railwatch/assets/app-layout-by7G3sa1.js +0 -41
  373. data/public/railwatch/assets/application-Cc5v_Vk9.css +0 -1
  374. data/public/railwatch/assets/arrow-up-DCZ7emux.js +0 -1
  375. data/public/railwatch/assets/braces-CykT5_5u.js +0 -1
  376. data/public/railwatch/assets/card-C-PPSA0g.js +0 -1
  377. data/public/railwatch/assets/chart-Cov79YC5.js +0 -39
  378. data/public/railwatch/assets/chart-hover-DdK4E-2h.js +0 -1
  379. data/public/railwatch/assets/chart-panel-BPaYRXJB.js +0 -1
  380. data/public/railwatch/assets/code-BYjC4YZ_.js +0 -1
  381. data/public/railwatch/assets/copy-id-LPfoj9cC.js +0 -1
  382. data/public/railwatch/assets/cursor-load-more-DSdhgpGl.js +0 -1
  383. data/public/railwatch/assets/data-table-CQ_YRWQs.js +0 -1
  384. data/public/railwatch/assets/env-layout-CdMWbUkm.js +0 -1
  385. data/public/railwatch/assets/execution-path-7MYt-Qbn.js +0 -1
  386. data/public/railwatch/assets/filter-bar-CrzpQ_vD.js +0 -1
  387. data/public/railwatch/assets/flamegraph-BIFEdTAg.js +0 -2
  388. data/public/railwatch/assets/format-B9SDkrWj.js +0 -1
  389. data/public/railwatch/assets/frames-C5t-x4f0.js +0 -1
  390. data/public/railwatch/assets/index--NQ7us-L.js +0 -1
  391. data/public/railwatch/assets/index-2kgYe_99.js +0 -1
  392. data/public/railwatch/assets/index-4YEPHxtO.js +0 -1
  393. data/public/railwatch/assets/index-AOCC1GDJ.js +0 -1
  394. data/public/railwatch/assets/index-BEo-u0eO.js +0 -1
  395. data/public/railwatch/assets/index-BJ1V3w2N.js +0 -1
  396. data/public/railwatch/assets/index-BPoHsBKb.js +0 -1
  397. data/public/railwatch/assets/index-BZzyGUM3.js +0 -1
  398. data/public/railwatch/assets/index-BgVq7yKL.js +0 -1
  399. data/public/railwatch/assets/index-BqFGEZvW.js +0 -1
  400. data/public/railwatch/assets/index-ByuHJ1CH.js +0 -1
  401. data/public/railwatch/assets/index-C2j50AL2.js +0 -1
  402. data/public/railwatch/assets/index-CLEdTkvW.js +0 -1
  403. data/public/railwatch/assets/index-CWgq8qBu.js +0 -1
  404. data/public/railwatch/assets/index-CXQ7_oVj.js +0 -2
  405. data/public/railwatch/assets/index-CmyYrjS2.js +0 -1
  406. data/public/railwatch/assets/index-Cn9_HjDo.js +0 -1
  407. data/public/railwatch/assets/index-CsMAm9UK.js +0 -1
  408. data/public/railwatch/assets/index-D0Kk_oGY.js +0 -1
  409. data/public/railwatch/assets/index-DC9ZptJh.js +0 -1
  410. data/public/railwatch/assets/index-DNzg7jmV.js +0 -1
  411. data/public/railwatch/assets/index-DQ8uJO5f.js +0 -1
  412. data/public/railwatch/assets/index-Deq14BxB.js +0 -1
  413. data/public/railwatch/assets/index-IaV3Blzp.js +0 -1
  414. data/public/railwatch/assets/index-TYNWcJQs.js +0 -1
  415. data/public/railwatch/assets/index-WonH2fcZ.js +0 -1
  416. data/public/railwatch/assets/index-WxWUaH_d.js +0 -1
  417. data/public/railwatch/assets/index-mUNrl4ky.js +0 -1
  418. data/public/railwatch/assets/inertia-DfNRARuA.js +0 -97
  419. data/public/railwatch/assets/json-viewer-DMsF5glp.js +0 -1
  420. data/public/railwatch/assets/klass-CmHYk5rz.js +0 -1
  421. data/public/railwatch/assets/origin-identity-1R3bqa3R.js +0 -1
  422. data/public/railwatch/assets/percentile-picker-73z5PAps.js +0 -1
  423. data/public/railwatch/assets/relative-time-Dsc_cCdX.js +0 -1
  424. data/public/railwatch/assets/release-health-BV5OaOWd.js +0 -1
  425. data/public/railwatch/assets/route-CWh58C7K.js +0 -1
  426. data/public/railwatch/assets/segmented-DB4zh-Am.js +0 -1
  427. data/public/railwatch/assets/select-DBKjFlOX.js +0 -1
  428. data/public/railwatch/assets/series-chart-BaiDG795.js +0 -1
  429. data/public/railwatch/assets/show-B4ICCFGh.js +0 -1
  430. data/public/railwatch/assets/show-B5lOU9Gv.js +0 -1
  431. data/public/railwatch/assets/show-BYPB74t4.js +0 -2
  432. data/public/railwatch/assets/show-C7A0m2M8.js +0 -1
  433. data/public/railwatch/assets/show-DT5X1zqE.js +0 -1
  434. data/public/railwatch/assets/show-D_NBdXsn.js +0 -2
  435. data/public/railwatch/assets/show-DgPCnRg8.js +0 -1
  436. data/public/railwatch/assets/show-Yl69HF0x.js +0 -1
  437. data/public/railwatch/assets/show-_QxuUQCm.js +0 -3
  438. data/public/railwatch/assets/show-g__jnN36.js +0 -1
  439. data/public/railwatch/assets/show-l4ztfjl9.js +0 -1
  440. data/public/railwatch/assets/sort-header-BcG8ulEH.js +0 -1
  441. data/public/railwatch/assets/source-link-B2183i2-.js +0 -1
  442. data/public/railwatch/assets/sparkline-cell-B47ipscB.js +0 -1
  443. data/public/railwatch/assets/stat-DT2hhsqS.js +0 -1
  444. data/public/railwatch/assets/status-badge-CmbRKlY7.js +0 -1
  445. data/public/railwatch/assets/tenant-path-C4rNGauE.js +0 -1
  446. data/public/railwatch/assets/use-clipboard-oTNeP4ax.js +0 -1
  447. data/public/railwatch/assets/use-live-CLZZj245.js +0 -1
  448. data/public/railwatch/icon.png +0 -0
  449. data/public/railwatch/icon.svg +0 -5
  450. data/public/railwatch/manifest.json +0 -1245
  451. data/public/railwatch/rails-vite.json +0 -1
  452. /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
- }