railwatch 0.6.1 → 0.7.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 (345) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +13 -0
  3. data/app/frontend/components/app-content.tsx +26 -0
  4. data/app/frontend/components/app-logo-icon.tsx +23 -0
  5. data/app/frontend/components/app-logo.tsx +14 -0
  6. data/app/frontend/components/app-shell.tsx +38 -0
  7. data/app/frontend/components/app-sidebar-header.tsx +54 -0
  8. data/app/frontend/components/app-sidebar.tsx +94 -0
  9. data/app/frontend/components/app-wordmark.tsx +29 -0
  10. data/app/frontend/components/breadcrumbs.tsx +46 -0
  11. data/app/frontend/components/icon.tsx +16 -0
  12. data/app/frontend/components/input-error.tsx +18 -0
  13. data/app/frontend/components/nav-footer.tsx +46 -0
  14. data/app/frontend/components/nav-main.tsx +41 -0
  15. data/app/frontend/components/nav-user.tsx +50 -0
  16. data/app/frontend/components/railwatch/chart-hover.tsx +34 -0
  17. data/app/frontend/components/railwatch/chart-panel.tsx +170 -0
  18. data/app/frontend/components/railwatch/code.tsx +28 -0
  19. data/app/frontend/components/railwatch/command-palette.tsx +132 -0
  20. data/app/frontend/components/railwatch/copy-block.tsx +52 -0
  21. data/app/frontend/components/railwatch/copy-id.tsx +49 -0
  22. data/app/frontend/components/railwatch/cursor-load-more.tsx +39 -0
  23. data/app/frontend/components/railwatch/data-table.tsx +195 -0
  24. data/app/frontend/components/railwatch/digit-roll.tsx +312 -0
  25. data/app/frontend/components/railwatch/empty-state.tsx +159 -0
  26. data/app/frontend/components/railwatch/env-nav.tsx +130 -0
  27. data/app/frontend/components/railwatch/env-switcher.tsx +145 -0
  28. data/app/frontend/components/railwatch/filter-bar.tsx +153 -0
  29. data/app/frontend/components/railwatch/flamegraph.tsx +324 -0
  30. data/app/frontend/components/railwatch/frames.tsx +295 -0
  31. data/app/frontend/components/railwatch/json-viewer.tsx +159 -0
  32. data/app/frontend/components/railwatch/live-dot.tsx +78 -0
  33. data/app/frontend/components/railwatch/live-toggle.tsx +76 -0
  34. data/app/frontend/components/railwatch/metric-chart.tsx +112 -0
  35. data/app/frontend/components/railwatch/mobile-page-switcher.tsx +89 -0
  36. data/app/frontend/components/railwatch/onboarding.tsx +91 -0
  37. data/app/frontend/components/railwatch/origin-identity.tsx +84 -0
  38. data/app/frontend/components/railwatch/page-header.tsx +42 -0
  39. data/app/frontend/components/railwatch/percentile-picker.tsx +18 -0
  40. data/app/frontend/components/railwatch/relative-time.tsx +18 -0
  41. data/app/frontend/components/railwatch/saved-views.tsx +232 -0
  42. data/app/frontend/components/railwatch/segmented.tsx +64 -0
  43. data/app/frontend/components/railwatch/series-chart.tsx +400 -0
  44. data/app/frontend/components/railwatch/shortcuts-dialog.tsx +89 -0
  45. data/app/frontend/components/railwatch/signal-toast.tsx +121 -0
  46. data/app/frontend/components/railwatch/sort-header.tsx +37 -0
  47. data/app/frontend/components/railwatch/source-link.tsx +57 -0
  48. data/app/frontend/components/railwatch/sparkline-cell.tsx +14 -0
  49. data/app/frontend/components/railwatch/sql-block.tsx +116 -0
  50. data/app/frontend/components/railwatch/stat.tsx +173 -0
  51. data/app/frontend/components/railwatch/status-badge.tsx +79 -0
  52. data/app/frontend/components/railwatch/step-picker.tsx +57 -0
  53. data/app/frontend/components/railwatch/timeline.tsx +405 -0
  54. data/app/frontend/components/railwatch/utilisation-bar.tsx +44 -0
  55. data/app/frontend/components/railwatch/window-picker.tsx +147 -0
  56. data/app/frontend/components/ui/avatar.tsx +51 -0
  57. data/app/frontend/components/ui/badge.tsx +46 -0
  58. data/app/frontend/components/ui/breadcrumb.tsx +109 -0
  59. data/app/frontend/components/ui/button.tsx +60 -0
  60. data/app/frontend/components/ui/card.tsx +92 -0
  61. data/app/frontend/components/ui/chart.tsx +374 -0
  62. data/app/frontend/components/ui/checkbox.tsx +32 -0
  63. data/app/frontend/components/ui/collapsible.tsx +31 -0
  64. data/app/frontend/components/ui/command.tsx +182 -0
  65. data/app/frontend/components/ui/dialog.tsx +158 -0
  66. data/app/frontend/components/ui/dropdown-menu.tsx +255 -0
  67. data/app/frontend/components/ui/hover-card.tsx +42 -0
  68. data/app/frontend/components/ui/input.tsx +21 -0
  69. data/app/frontend/components/ui/label.tsx +24 -0
  70. data/app/frontend/components/ui/popover.tsx +87 -0
  71. data/app/frontend/components/ui/select.tsx +185 -0
  72. data/app/frontend/components/ui/separator.tsx +28 -0
  73. data/app/frontend/components/ui/sheet.tsx +137 -0
  74. data/app/frontend/components/ui/sidebar.tsx +726 -0
  75. data/app/frontend/components/ui/skeleton.tsx +13 -0
  76. data/app/frontend/components/ui/sonner.tsx +41 -0
  77. data/app/frontend/components/ui/table.tsx +114 -0
  78. data/app/frontend/components/ui/tabs.tsx +91 -0
  79. data/app/frontend/components/ui/textarea.tsx +18 -0
  80. data/app/frontend/components/ui/tooltip.tsx +61 -0
  81. data/app/frontend/components/user-info.tsx +32 -0
  82. data/app/frontend/components/user-menu-content.tsx +94 -0
  83. data/app/frontend/entrypoints/application.css +244 -0
  84. data/app/frontend/entrypoints/inertia.tsx +95 -0
  85. data/app/frontend/fonts/CommitMono-Bold.woff2 +0 -0
  86. data/app/frontend/fonts/CommitMono-Regular.woff2 +0 -0
  87. data/app/frontend/fonts/Roboto-Black.woff2 +0 -0
  88. data/app/frontend/fonts/Roboto-Bold.woff2 +0 -0
  89. data/app/frontend/fonts/Roboto-Medium.woff2 +0 -0
  90. data/app/frontend/fonts/Roboto-Regular.woff2 +0 -0
  91. data/app/frontend/hooks/use-appearance.tsx +67 -0
  92. data/app/frontend/hooks/use-clipboard.ts +32 -0
  93. data/app/frontend/hooks/use-flash.tsx +23 -0
  94. data/app/frontend/hooks/use-hotkeys.ts +63 -0
  95. data/app/frontend/hooks/use-initials.tsx +15 -0
  96. data/app/frontend/hooks/use-live.ts +179 -0
  97. data/app/frontend/hooks/use-mobile-navigation.ts +8 -0
  98. data/app/frontend/hooks/use-mobile.ts +21 -0
  99. data/app/frontend/hooks/use-percentile.ts +24 -0
  100. data/app/frontend/hooks/use-reduced-motion.ts +25 -0
  101. data/app/frontend/hooks/use-row-nav.ts +106 -0
  102. data/app/frontend/hooks/use-step.ts +27 -0
  103. data/app/frontend/hooks/use-ticker.ts +12 -0
  104. data/app/frontend/hooks/use-window.ts +58 -0
  105. data/app/frontend/layouts/app/app-sidebar-layout.tsx +107 -0
  106. data/app/frontend/layouts/app-layout.tsx +21 -0
  107. data/app/frontend/layouts/env-layout.tsx +53 -0
  108. data/app/frontend/layouts/persistent-layout.tsx +18 -0
  109. data/app/frontend/lib/cable.ts +10 -0
  110. data/app/frontend/lib/env-pages.ts +236 -0
  111. data/app/frontend/lib/execution-path.ts +55 -0
  112. data/app/frontend/lib/explain.ts +50 -0
  113. data/app/frontend/lib/filter.ts +25 -0
  114. data/app/frontend/lib/flamegraph.ts +155 -0
  115. data/app/frontend/lib/format.ts +71 -0
  116. data/app/frontend/lib/keyboard.ts +18 -0
  117. data/app/frontend/lib/source-link.ts +217 -0
  118. data/app/frontend/lib/utils.ts +6 -0
  119. data/app/frontend/pages/alerts/index.tsx +171 -0
  120. data/app/frontend/pages/broadcasts/index.tsx +171 -0
  121. data/app/frontend/pages/cache_events/index.tsx +127 -0
  122. data/app/frontend/pages/commands/index.tsx +143 -0
  123. data/app/frontend/pages/deploys/index.tsx +94 -0
  124. data/app/frontend/pages/deploys/show.tsx +309 -0
  125. data/app/frontend/pages/deprecations/index.tsx +176 -0
  126. data/app/frontend/pages/exceptions/index.tsx +271 -0
  127. data/app/frontend/pages/executions/show.tsx +821 -0
  128. data/app/frontend/pages/issues/index.tsx +399 -0
  129. data/app/frontend/pages/issues/show.tsx +1367 -0
  130. data/app/frontend/pages/jobs/index.tsx +381 -0
  131. data/app/frontend/pages/jobs/klass.tsx +215 -0
  132. data/app/frontend/pages/llm_calls/index.tsx +664 -0
  133. data/app/frontend/pages/logs/index.tsx +303 -0
  134. data/app/frontend/pages/mails/index.tsx +218 -0
  135. data/app/frontend/pages/notifications/index.tsx +161 -0
  136. data/app/frontend/pages/outgoing_requests/index.tsx +309 -0
  137. data/app/frontend/pages/overview/show.tsx +333 -0
  138. data/app/frontend/pages/people/index.tsx +102 -0
  139. data/app/frontend/pages/people/show.tsx +149 -0
  140. data/app/frontend/pages/processes/index.tsx +335 -0
  141. data/app/frontend/pages/profiles/index.tsx +110 -0
  142. data/app/frontend/pages/profiles/show.tsx +105 -0
  143. data/app/frontend/pages/queries/index.tsx +492 -0
  144. data/app/frontend/pages/queries/show.tsx +276 -0
  145. data/app/frontend/pages/releases/index.tsx +190 -0
  146. data/app/frontend/pages/releases/release-health.tsx +68 -0
  147. data/app/frontend/pages/releases/release-path.ts +25 -0
  148. data/app/frontend/pages/releases/show.tsx +262 -0
  149. data/app/frontend/pages/requests/index.tsx +235 -0
  150. data/app/frontend/pages/requests/route.tsx +159 -0
  151. data/app/frontend/pages/scheduled_tasks/index.tsx +220 -0
  152. data/app/frontend/pages/spans/index.tsx +229 -0
  153. data/app/frontend/pages/spans/show.tsx +220 -0
  154. data/app/frontend/pages/storage_ops/index.tsx +150 -0
  155. data/app/frontend/pages/tenants/index.tsx +236 -0
  156. data/app/frontend/pages/tenants/show.tsx +477 -0
  157. data/app/frontend/pages/tenants/tenant-path.ts +26 -0
  158. data/app/frontend/pages/thresholds/index.tsx +414 -0
  159. data/app/frontend/pages/traces/show.tsx +208 -0
  160. data/app/frontend/pages/transactions/index.tsx +220 -0
  161. data/app/frontend/pages/view_renders/index.tsx +241 -0
  162. data/app/frontend/pages/visits/index.tsx +321 -0
  163. data/app/frontend/public/icon.png +0 -0
  164. data/app/frontend/public/icon.svg +5 -0
  165. data/app/frontend/routes/index.d.ts +2136 -0
  166. data/app/frontend/routes/index.js +2112 -0
  167. data/app/frontend/types/globals.d.ts +9 -0
  168. data/app/frontend/types/index.ts +288 -0
  169. data/app/frontend/types/vite-env.d.ts +9 -0
  170. data/lib/railwatch/version.rb +1 -1
  171. data/lib/railwatch.rb +8 -3
  172. data/public/railwatch/assets/app-layout-LvxN9xSS.js +41 -0
  173. data/public/railwatch/assets/application-Cc5v_Vk9.css +1 -0
  174. data/public/railwatch/assets/{arrow-up-CLQ-7heQ.js → arrow-up-DG01k9AL.js} +1 -1
  175. data/public/railwatch/assets/{braces-rSYydpLY.js → braces-C3w7pfxS.js} +1 -1
  176. data/public/railwatch/assets/{card-DPjFKfen.js → card-Dpwlr0uY.js} +1 -1
  177. data/public/railwatch/assets/{chart-DWh7l8yM.js → chart-DJHv3R7W.js} +1 -1
  178. data/public/railwatch/assets/{chart-hover-CfoZUY4J.js → chart-hover-CEH-DtXO.js} +1 -1
  179. data/public/railwatch/assets/{chart-panel-CC49WTQL.js → chart-panel-CUdIzJvk.js} +1 -1
  180. data/public/railwatch/assets/code-BRZprwun.js +1 -0
  181. data/public/railwatch/assets/copy-id-CwhGGiwF.js +1 -0
  182. data/public/railwatch/assets/cursor-load-more-uF1_955Q.js +1 -0
  183. data/public/railwatch/assets/data-table-DYi-N0hm.js +1 -0
  184. data/public/railwatch/assets/env-layout-HdcOaPPX.js +1 -0
  185. data/public/railwatch/assets/{execution-path-FAuIzOXB.js → execution-path-CElXXFO_.js} +1 -1
  186. data/public/railwatch/assets/filter-bar-DEv0G_34.js +1 -0
  187. data/public/railwatch/assets/{flamegraph-EaGkP2NT.js → flamegraph-SKD9yxW9.js} +2 -2
  188. data/public/railwatch/assets/frames-CUpyJQDy.js +1 -0
  189. data/public/railwatch/assets/index-2W-ppwap.js +1 -0
  190. data/public/railwatch/assets/index-74ocuU5d.js +1 -0
  191. data/public/railwatch/assets/{index-C8Cggnbw.js → index-B8NA62Lz.js} +2 -2
  192. data/public/railwatch/assets/index-BBCEkLqp.js +1 -0
  193. data/public/railwatch/assets/index-BDcglJzX.js +1 -0
  194. data/public/railwatch/assets/index-B_KFb9LS.js +1 -0
  195. data/public/railwatch/assets/index-BqK_VKom.js +1 -0
  196. data/public/railwatch/assets/index-BxCXBHP2.js +1 -0
  197. data/public/railwatch/assets/index-C1jc1OoH.js +1 -0
  198. data/public/railwatch/assets/index-C7zREldp.js +1 -0
  199. data/public/railwatch/assets/index-CEetm4oC.js +1 -0
  200. data/public/railwatch/assets/index-CIaZkokF.js +1 -0
  201. data/public/railwatch/assets/index-CNH85TZp.js +1 -0
  202. data/public/railwatch/assets/index-Ce4SB5AB.js +1 -0
  203. data/public/railwatch/assets/index-CiRMSYMn.js +1 -0
  204. data/public/railwatch/assets/index-CjxXgOs-.js +1 -0
  205. data/public/railwatch/assets/index-ClL-PYEV.js +1 -0
  206. data/public/railwatch/assets/index-CteZ6fop.js +1 -0
  207. data/public/railwatch/assets/index-CvqfEExO.js +1 -0
  208. data/public/railwatch/assets/index-CxTLT-Qh.js +1 -0
  209. data/public/railwatch/assets/index-DJd3OUSd.js +1 -0
  210. data/public/railwatch/assets/index-DPD-rLp6.js +1 -0
  211. data/public/railwatch/assets/index-DYK_dps4.js +1 -0
  212. data/public/railwatch/assets/index-DrayARrI.js +1 -0
  213. data/public/railwatch/assets/index-HRXGir-0.js +1 -0
  214. data/public/railwatch/assets/index-UJOUALr_.js +1 -0
  215. data/public/railwatch/assets/index-d-3J7gQ5.js +1 -0
  216. data/public/railwatch/assets/index-oQrvBpOm.js +1 -0
  217. data/public/railwatch/assets/inertia-FoqYZAk8.js +97 -0
  218. data/public/railwatch/assets/json-viewer-CxqMojEm.js +1 -0
  219. data/public/railwatch/assets/klass-DEF-Vat5.js +1 -0
  220. data/public/railwatch/assets/origin-identity-Y6gvfQVR.js +1 -0
  221. data/public/railwatch/assets/{percentile-picker-CeQgllxD.js → percentile-picker-E2aUdLxx.js} +1 -1
  222. data/public/railwatch/assets/relative-time-DcJbkKlN.js +1 -0
  223. data/public/railwatch/assets/{release-health-uP-GF3_V.js → release-health-C8WSCDKD.js} +1 -1
  224. data/public/railwatch/assets/route-qO5vX1p-.js +1 -0
  225. data/public/railwatch/assets/{segmented-BeIe4uqk.js → segmented-BWee8uc9.js} +1 -1
  226. data/public/railwatch/assets/select-G7rNBzv0.js +1 -0
  227. data/public/railwatch/assets/series-chart-C5nXIUc5.js +1 -0
  228. data/public/railwatch/assets/show-BEWisLQw.js +1 -0
  229. data/public/railwatch/assets/show-BEa9PGu1.js +2 -0
  230. data/public/railwatch/assets/{show-YsNpqbcI.js → show-BiT_Aloc.js} +3 -3
  231. data/public/railwatch/assets/show-BpCO49d3.js +1 -0
  232. data/public/railwatch/assets/show-CH7yxt7R.js +1 -0
  233. data/public/railwatch/assets/show-CTO8Lvd2.js +1 -0
  234. data/public/railwatch/assets/show-DnYsDMUt.js +2 -0
  235. data/public/railwatch/assets/show-DyzHM527.js +1 -0
  236. data/public/railwatch/assets/show-Nfpxs8su.js +1 -0
  237. data/public/railwatch/assets/show-ddjUMo9G.js +1 -0
  238. data/public/railwatch/assets/show-ksjbSzDH.js +1 -0
  239. data/public/railwatch/assets/{sort-header-mEJUM3Av.js → sort-header-uOq5VlrB.js} +1 -1
  240. data/public/railwatch/assets/{sparkline-cell-Dwg7awwh.js → sparkline-cell-DgGij0cu.js} +1 -1
  241. data/public/railwatch/assets/stat-BTzNKTm2.js +1 -0
  242. data/public/railwatch/assets/status-badge-BDkOXa-C.js +1 -0
  243. data/public/railwatch/assets/{tenant-path-CQoP-BeF.js → tenant-path-DU0ezKSU.js} +1 -1
  244. data/public/railwatch/assets/{use-clipboard-DNefo-ky.js → use-clipboard-DoENmlWY.js} +1 -1
  245. data/public/railwatch/assets/{use-live-DMTuhKfB.js → use-live-CE_fN5_6.js} +1 -1
  246. data/public/railwatch/manifest.json +612 -1591
  247. metadata +242 -117
  248. data/public/railwatch/assets/app-layout-Zc0v-hYh.js +0 -1
  249. data/public/railwatch/assets/app-wordmark-BA_60AVb.js +0 -1
  250. data/public/railwatch/assets/appearance-CcfP9tZ7.js +0 -1
  251. data/public/railwatch/assets/application-BrN3Sz94.css +0 -1
  252. data/public/railwatch/assets/auth-layout-C05gEBIQ.js +0 -1
  253. data/public/railwatch/assets/badge-DRae8XwK.js +0 -1
  254. data/public/railwatch/assets/checkbox-DPkLUiwM.js +0 -1
  255. data/public/railwatch/assets/code-CLmYS6FU.js +0 -1
  256. data/public/railwatch/assets/copy-DaQMJWoT.js +0 -1
  257. data/public/railwatch/assets/copy-block-CGcXxp8J.js +0 -1
  258. data/public/railwatch/assets/copy-id-vBYHQwxg.js +0 -1
  259. data/public/railwatch/assets/cursor-load-more-Ddwxe5dZ.js +0 -1
  260. data/public/railwatch/assets/data-table-CzKTEE-O.js +0 -1
  261. data/public/railwatch/assets/edit-B1kmWkzd.js +0 -8
  262. data/public/railwatch/assets/edit-D9cx4pbG.js +0 -1
  263. data/public/railwatch/assets/edit-yv6j9p-V.js +0 -1
  264. data/public/railwatch/assets/empty-state-CW4wclK_.js +0 -1
  265. data/public/railwatch/assets/env-layout-Kz7wks1x.js +0 -1
  266. data/public/railwatch/assets/filter-bar-CJDjWFib.js +0 -1
  267. data/public/railwatch/assets/frames-zZuIaNH9.js +0 -1
  268. data/public/railwatch/assets/google-sign-in-button-2_zgbgVy.js +0 -1
  269. data/public/railwatch/assets/index-B49SWz7K.js +0 -1
  270. data/public/railwatch/assets/index-BHlY4wKe.js +0 -1
  271. data/public/railwatch/assets/index-BZpPtyFY.js +0 -1
  272. data/public/railwatch/assets/index-BeVK6jCL.js +0 -1
  273. data/public/railwatch/assets/index-BfbSo01U.js +0 -1
  274. data/public/railwatch/assets/index-BfgncAv6.js +0 -1
  275. data/public/railwatch/assets/index-BhNszK1k.js +0 -1
  276. data/public/railwatch/assets/index-Bu01uWvw.js +0 -1
  277. data/public/railwatch/assets/index-C1s_hK3p.js +0 -1
  278. data/public/railwatch/assets/index-CBip6V4z.js +0 -1
  279. data/public/railwatch/assets/index-CMJGss5R.js +0 -1
  280. data/public/railwatch/assets/index-CRo3yK20.js +0 -1
  281. data/public/railwatch/assets/index-CWB_p2J8.js +0 -1
  282. data/public/railwatch/assets/index-CWHpndGc.js +0 -1
  283. data/public/railwatch/assets/index-Ca_S4Sc3.js +0 -1
  284. data/public/railwatch/assets/index-CanPDDOa.js +0 -1
  285. data/public/railwatch/assets/index-Cie90Yat.js +0 -1
  286. data/public/railwatch/assets/index-CiepQ_pR.js +0 -1
  287. data/public/railwatch/assets/index-Cm1uCIGN.js +0 -1
  288. data/public/railwatch/assets/index-CzitnnSC.js +0 -1
  289. data/public/railwatch/assets/index-DEUfClv3.js +0 -1
  290. data/public/railwatch/assets/index-DJKwo-mI.js +0 -1
  291. data/public/railwatch/assets/index-DK6y0YHp.js +0 -1
  292. data/public/railwatch/assets/index-DMNPpLH9.js +0 -1
  293. data/public/railwatch/assets/index-DZO1mSfX.js +0 -1
  294. data/public/railwatch/assets/index-Db5wj3M5.js +0 -1
  295. data/public/railwatch/assets/index-Dcy5WktB.js +0 -1
  296. data/public/railwatch/assets/index-DvG0-7Lx.js +0 -1
  297. data/public/railwatch/assets/index-Dvj1wuka.js +0 -1
  298. data/public/railwatch/assets/index-KyZX46qX.js +0 -1
  299. data/public/railwatch/assets/index-Ze-KP-sl.js +0 -1
  300. data/public/railwatch/assets/index-mhRbWLBM.js +0 -1
  301. data/public/railwatch/assets/index-x099JL5f.js +0 -1
  302. data/public/railwatch/assets/index-x28zb_nf.js +0 -1
  303. data/public/railwatch/assets/inertia-Cuyz2ZHO.js +0 -97
  304. data/public/railwatch/assets/input-error-hog6gGxg.js +0 -1
  305. data/public/railwatch/assets/json-viewer-DH2W9HXf.js +0 -1
  306. data/public/railwatch/assets/klass-DHbDelLk.js +0 -1
  307. data/public/railwatch/assets/label-DdCBgiUn.js +0 -1
  308. data/public/railwatch/assets/layout-Cueyl7c5.js +0 -1
  309. data/public/railwatch/assets/live-dot-BZgYTYdt.js +0 -41
  310. data/public/railwatch/assets/nav-BSSGObDZ.js +0 -1
  311. data/public/railwatch/assets/new-B8FSb8Bl.js +0 -1
  312. data/public/railwatch/assets/new-C39_v2Ll.js +0 -1
  313. data/public/railwatch/assets/new-DVOPwaC5.js +0 -1
  314. data/public/railwatch/assets/new-Dd40nvJR.js +0 -1
  315. data/public/railwatch/assets/new-SxnYe1SE.js +0 -1
  316. data/public/railwatch/assets/new-eP5vKD3Y.js +0 -1
  317. data/public/railwatch/assets/onboarding-CUpZl5KB.js +0 -1
  318. data/public/railwatch/assets/origin-identity-BYt2uiuo.js +0 -1
  319. data/public/railwatch/assets/relative-time-D5UbF4oO.js +0 -1
  320. data/public/railwatch/assets/route-DQAY8JEr.js +0 -1
  321. data/public/railwatch/assets/select-2R16457Z.js +0 -1
  322. data/public/railwatch/assets/separator-D5I0UCB5.js +0 -1
  323. data/public/railwatch/assets/series-chart-gnrzhmM6.js +0 -1
  324. data/public/railwatch/assets/show-2BkeNRUC.js +0 -2
  325. data/public/railwatch/assets/show-B0X1hRQH.js +0 -1
  326. data/public/railwatch/assets/show-BKUV5l5q.js +0 -1
  327. data/public/railwatch/assets/show-BQJD_CsF.js +0 -6
  328. data/public/railwatch/assets/show-BhmD6Sxp.js +0 -1
  329. data/public/railwatch/assets/show-C3KcnVo2.js +0 -1
  330. data/public/railwatch/assets/show-C95cHd06.js +0 -2
  331. data/public/railwatch/assets/show-CdB4uVQS.js +0 -1
  332. data/public/railwatch/assets/show-CoCqcVIp.js +0 -1
  333. data/public/railwatch/assets/show-D2LqjEsU.js +0 -1
  334. data/public/railwatch/assets/show-DNpnKyTh.js +0 -1
  335. data/public/railwatch/assets/show-DOlTxbig.js +0 -1
  336. data/public/railwatch/assets/show-DQCkttL8.js +0 -1
  337. data/public/railwatch/assets/show-DcxesipC.js +0 -1
  338. data/public/railwatch/assets/show-IGJoc_X0.js +0 -1
  339. data/public/railwatch/assets/show-qNV6H8SH.js +0 -2
  340. data/public/railwatch/assets/stat-ZtxGU8lE.js +0 -1
  341. data/public/railwatch/assets/status-badge-CH5P-Xkj.js +0 -1
  342. data/public/railwatch/assets/text-link-DHJ8BXx5.js +0 -1
  343. data/public/railwatch/assets/textarea-BeQtQyl5.js +0 -1
  344. data/public/railwatch/assets/timeline-CaKQVu48.js +0 -1
  345. data/public/railwatch/assets/transition-ksDpqhKJ.js +0 -5
@@ -0,0 +1,1367 @@
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
+ }