railwatch 0.6.0 → 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 (364) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +42 -26
  3. data/CHANGELOG.md +41 -0
  4. data/README.md +40 -29
  5. data/app/frontend/components/app-content.tsx +26 -0
  6. data/app/frontend/components/app-logo-icon.tsx +23 -0
  7. data/app/frontend/components/app-logo.tsx +14 -0
  8. data/app/frontend/components/app-shell.tsx +38 -0
  9. data/app/frontend/components/app-sidebar-header.tsx +54 -0
  10. data/app/frontend/components/app-sidebar.tsx +94 -0
  11. data/app/frontend/components/app-wordmark.tsx +29 -0
  12. data/app/frontend/components/breadcrumbs.tsx +46 -0
  13. data/app/frontend/components/icon.tsx +16 -0
  14. data/app/frontend/components/input-error.tsx +18 -0
  15. data/app/frontend/components/nav-footer.tsx +46 -0
  16. data/app/frontend/components/nav-main.tsx +41 -0
  17. data/app/frontend/components/nav-user.tsx +50 -0
  18. data/app/frontend/components/railwatch/chart-hover.tsx +34 -0
  19. data/app/frontend/components/railwatch/chart-panel.tsx +170 -0
  20. data/app/frontend/components/railwatch/code.tsx +28 -0
  21. data/app/frontend/components/railwatch/command-palette.tsx +132 -0
  22. data/app/frontend/components/railwatch/copy-block.tsx +52 -0
  23. data/app/frontend/components/railwatch/copy-id.tsx +49 -0
  24. data/app/frontend/components/railwatch/cursor-load-more.tsx +39 -0
  25. data/app/frontend/components/railwatch/data-table.tsx +195 -0
  26. data/app/frontend/components/railwatch/digit-roll.tsx +312 -0
  27. data/app/frontend/components/railwatch/empty-state.tsx +159 -0
  28. data/app/frontend/components/railwatch/env-nav.tsx +130 -0
  29. data/app/frontend/components/railwatch/env-switcher.tsx +145 -0
  30. data/app/frontend/components/railwatch/filter-bar.tsx +153 -0
  31. data/app/frontend/components/railwatch/flamegraph.tsx +324 -0
  32. data/app/frontend/components/railwatch/frames.tsx +295 -0
  33. data/app/frontend/components/railwatch/json-viewer.tsx +159 -0
  34. data/app/frontend/components/railwatch/live-dot.tsx +78 -0
  35. data/app/frontend/components/railwatch/live-toggle.tsx +76 -0
  36. data/app/frontend/components/railwatch/metric-chart.tsx +112 -0
  37. data/app/frontend/components/railwatch/mobile-page-switcher.tsx +89 -0
  38. data/app/frontend/components/railwatch/onboarding.tsx +91 -0
  39. data/app/frontend/components/railwatch/origin-identity.tsx +84 -0
  40. data/app/frontend/components/railwatch/page-header.tsx +42 -0
  41. data/app/frontend/components/railwatch/percentile-picker.tsx +18 -0
  42. data/app/frontend/components/railwatch/relative-time.tsx +18 -0
  43. data/app/frontend/components/railwatch/saved-views.tsx +232 -0
  44. data/app/frontend/components/railwatch/segmented.tsx +64 -0
  45. data/app/frontend/components/railwatch/series-chart.tsx +400 -0
  46. data/app/frontend/components/railwatch/shortcuts-dialog.tsx +89 -0
  47. data/app/frontend/components/railwatch/signal-toast.tsx +121 -0
  48. data/app/frontend/components/railwatch/sort-header.tsx +37 -0
  49. data/app/frontend/components/railwatch/source-link.tsx +57 -0
  50. data/app/frontend/components/railwatch/sparkline-cell.tsx +14 -0
  51. data/app/frontend/components/railwatch/sql-block.tsx +116 -0
  52. data/app/frontend/components/railwatch/stat.tsx +173 -0
  53. data/app/frontend/components/railwatch/status-badge.tsx +79 -0
  54. data/app/frontend/components/railwatch/step-picker.tsx +57 -0
  55. data/app/frontend/components/railwatch/timeline.tsx +405 -0
  56. data/app/frontend/components/railwatch/utilisation-bar.tsx +44 -0
  57. data/app/frontend/components/railwatch/window-picker.tsx +147 -0
  58. data/app/frontend/components/ui/avatar.tsx +51 -0
  59. data/app/frontend/components/ui/badge.tsx +46 -0
  60. data/app/frontend/components/ui/breadcrumb.tsx +109 -0
  61. data/app/frontend/components/ui/button.tsx +60 -0
  62. data/app/frontend/components/ui/card.tsx +92 -0
  63. data/app/frontend/components/ui/chart.tsx +374 -0
  64. data/app/frontend/components/ui/checkbox.tsx +32 -0
  65. data/app/frontend/components/ui/collapsible.tsx +31 -0
  66. data/app/frontend/components/ui/command.tsx +182 -0
  67. data/app/frontend/components/ui/dialog.tsx +158 -0
  68. data/app/frontend/components/ui/dropdown-menu.tsx +255 -0
  69. data/app/frontend/components/ui/hover-card.tsx +42 -0
  70. data/app/frontend/components/ui/input.tsx +21 -0
  71. data/app/frontend/components/ui/label.tsx +24 -0
  72. data/app/frontend/components/ui/popover.tsx +87 -0
  73. data/app/frontend/components/ui/select.tsx +185 -0
  74. data/app/frontend/components/ui/separator.tsx +28 -0
  75. data/app/frontend/components/ui/sheet.tsx +137 -0
  76. data/app/frontend/components/ui/sidebar.tsx +726 -0
  77. data/app/frontend/components/ui/skeleton.tsx +13 -0
  78. data/app/frontend/components/ui/sonner.tsx +41 -0
  79. data/app/frontend/components/ui/table.tsx +114 -0
  80. data/app/frontend/components/ui/tabs.tsx +91 -0
  81. data/app/frontend/components/ui/textarea.tsx +18 -0
  82. data/app/frontend/components/ui/tooltip.tsx +61 -0
  83. data/app/frontend/components/user-info.tsx +32 -0
  84. data/app/frontend/components/user-menu-content.tsx +94 -0
  85. data/app/frontend/entrypoints/application.css +244 -0
  86. data/app/frontend/entrypoints/inertia.tsx +95 -0
  87. data/app/frontend/fonts/CommitMono-Bold.woff2 +0 -0
  88. data/app/frontend/fonts/CommitMono-Regular.woff2 +0 -0
  89. data/app/frontend/fonts/Roboto-Black.woff2 +0 -0
  90. data/app/frontend/fonts/Roboto-Bold.woff2 +0 -0
  91. data/app/frontend/fonts/Roboto-Medium.woff2 +0 -0
  92. data/app/frontend/fonts/Roboto-Regular.woff2 +0 -0
  93. data/app/frontend/hooks/use-appearance.tsx +67 -0
  94. data/app/frontend/hooks/use-clipboard.ts +32 -0
  95. data/app/frontend/hooks/use-flash.tsx +23 -0
  96. data/app/frontend/hooks/use-hotkeys.ts +63 -0
  97. data/app/frontend/hooks/use-initials.tsx +15 -0
  98. data/app/frontend/hooks/use-live.ts +179 -0
  99. data/app/frontend/hooks/use-mobile-navigation.ts +8 -0
  100. data/app/frontend/hooks/use-mobile.ts +21 -0
  101. data/app/frontend/hooks/use-percentile.ts +24 -0
  102. data/app/frontend/hooks/use-reduced-motion.ts +25 -0
  103. data/app/frontend/hooks/use-row-nav.ts +106 -0
  104. data/app/frontend/hooks/use-step.ts +27 -0
  105. data/app/frontend/hooks/use-ticker.ts +12 -0
  106. data/app/frontend/hooks/use-window.ts +58 -0
  107. data/app/frontend/layouts/app/app-sidebar-layout.tsx +107 -0
  108. data/app/frontend/layouts/app-layout.tsx +21 -0
  109. data/app/frontend/layouts/env-layout.tsx +53 -0
  110. data/app/frontend/layouts/persistent-layout.tsx +18 -0
  111. data/app/frontend/lib/cable.ts +10 -0
  112. data/app/frontend/lib/env-pages.ts +236 -0
  113. data/app/frontend/lib/execution-path.ts +55 -0
  114. data/app/frontend/lib/explain.ts +50 -0
  115. data/app/frontend/lib/filter.ts +25 -0
  116. data/app/frontend/lib/flamegraph.ts +155 -0
  117. data/app/frontend/lib/format.ts +71 -0
  118. data/app/frontend/lib/keyboard.ts +18 -0
  119. data/app/frontend/lib/source-link.ts +217 -0
  120. data/app/frontend/lib/utils.ts +6 -0
  121. data/app/frontend/pages/alerts/index.tsx +171 -0
  122. data/app/frontend/pages/broadcasts/index.tsx +171 -0
  123. data/app/frontend/pages/cache_events/index.tsx +127 -0
  124. data/app/frontend/pages/commands/index.tsx +143 -0
  125. data/app/frontend/pages/deploys/index.tsx +94 -0
  126. data/app/frontend/pages/deploys/show.tsx +309 -0
  127. data/app/frontend/pages/deprecations/index.tsx +176 -0
  128. data/app/frontend/pages/exceptions/index.tsx +271 -0
  129. data/app/frontend/pages/executions/show.tsx +821 -0
  130. data/app/frontend/pages/issues/index.tsx +399 -0
  131. data/app/frontend/pages/issues/show.tsx +1367 -0
  132. data/app/frontend/pages/jobs/index.tsx +381 -0
  133. data/app/frontend/pages/jobs/klass.tsx +215 -0
  134. data/app/frontend/pages/llm_calls/index.tsx +664 -0
  135. data/app/frontend/pages/logs/index.tsx +303 -0
  136. data/app/frontend/pages/mails/index.tsx +218 -0
  137. data/app/frontend/pages/notifications/index.tsx +161 -0
  138. data/app/frontend/pages/outgoing_requests/index.tsx +309 -0
  139. data/app/frontend/pages/overview/show.tsx +333 -0
  140. data/app/frontend/pages/people/index.tsx +102 -0
  141. data/app/frontend/pages/people/show.tsx +149 -0
  142. data/app/frontend/pages/processes/index.tsx +335 -0
  143. data/app/frontend/pages/profiles/index.tsx +110 -0
  144. data/app/frontend/pages/profiles/show.tsx +105 -0
  145. data/app/frontend/pages/queries/index.tsx +492 -0
  146. data/app/frontend/pages/queries/show.tsx +276 -0
  147. data/app/frontend/pages/releases/index.tsx +190 -0
  148. data/app/frontend/pages/releases/release-health.tsx +68 -0
  149. data/app/frontend/pages/releases/release-path.ts +25 -0
  150. data/app/frontend/pages/releases/show.tsx +262 -0
  151. data/app/frontend/pages/requests/index.tsx +235 -0
  152. data/app/frontend/pages/requests/route.tsx +159 -0
  153. data/app/frontend/pages/scheduled_tasks/index.tsx +220 -0
  154. data/app/frontend/pages/spans/index.tsx +229 -0
  155. data/app/frontend/pages/spans/show.tsx +220 -0
  156. data/app/frontend/pages/storage_ops/index.tsx +150 -0
  157. data/app/frontend/pages/tenants/index.tsx +236 -0
  158. data/app/frontend/pages/tenants/show.tsx +477 -0
  159. data/app/frontend/pages/tenants/tenant-path.ts +26 -0
  160. data/app/frontend/pages/thresholds/index.tsx +414 -0
  161. data/app/frontend/pages/traces/show.tsx +208 -0
  162. data/app/frontend/pages/transactions/index.tsx +220 -0
  163. data/app/frontend/pages/view_renders/index.tsx +241 -0
  164. data/app/frontend/pages/visits/index.tsx +321 -0
  165. data/app/frontend/public/icon.png +0 -0
  166. data/app/frontend/public/icon.svg +5 -0
  167. data/app/frontend/routes/index.d.ts +2136 -0
  168. data/app/frontend/routes/index.js +2112 -0
  169. data/app/frontend/types/globals.d.ts +9 -0
  170. data/app/frontend/types/index.ts +288 -0
  171. data/app/frontend/types/vite-env.d.ts +9 -0
  172. data/app/jobs/railwatch/rollup_job.rb +3 -1
  173. data/app/models/railwatch/ingest/rollup_absorber.rb +1 -1
  174. data/docs/ai-and-mcp.md +9 -3
  175. data/docs/configuration.md +68 -27
  176. data/docs/embedded.md +76 -43
  177. data/docs/faq.md +22 -10
  178. data/docs/getting-started.md +116 -41
  179. data/docs/records.md +13 -10
  180. data/docs/replacing-nightwatch.md +16 -14
  181. data/docs/replacing-sentry.md +19 -10
  182. data/docs/security.md +24 -3
  183. data/docs/self-hosting.md +9 -1
  184. data/docs/testing.md +14 -4
  185. data/docs/troubleshooting.md +62 -23
  186. data/lib/generators/railwatch/install/install_generator.rb +5 -4
  187. data/lib/generators/railwatch/install/templates/initializer.rb.tt +2 -2
  188. data/lib/railwatch/version.rb +1 -1
  189. data/lib/railwatch.rb +8 -3
  190. data/llms.txt +21 -18
  191. data/public/railwatch/assets/app-layout-LvxN9xSS.js +41 -0
  192. data/public/railwatch/assets/application-Cc5v_Vk9.css +1 -0
  193. data/public/railwatch/assets/{arrow-up-EmePsTKP.js → arrow-up-DG01k9AL.js} +1 -1
  194. data/public/railwatch/assets/{braces-iZ7MEfYP.js → braces-C3w7pfxS.js} +1 -1
  195. data/public/railwatch/assets/{card-DqdEEKcJ.js → card-Dpwlr0uY.js} +1 -1
  196. data/public/railwatch/assets/{chart-C4k7Ub9W.js → chart-DJHv3R7W.js} +1 -1
  197. data/public/railwatch/assets/{chart-hover-2yZG3w2u.js → chart-hover-CEH-DtXO.js} +1 -1
  198. data/public/railwatch/assets/{chart-panel-CONkZcn9.js → chart-panel-CUdIzJvk.js} +1 -1
  199. data/public/railwatch/assets/code-BRZprwun.js +1 -0
  200. data/public/railwatch/assets/copy-id-CwhGGiwF.js +1 -0
  201. data/public/railwatch/assets/cursor-load-more-uF1_955Q.js +1 -0
  202. data/public/railwatch/assets/data-table-DYi-N0hm.js +1 -0
  203. data/public/railwatch/assets/env-layout-HdcOaPPX.js +1 -0
  204. data/public/railwatch/assets/{execution-path-DWovhgLi.js → execution-path-CElXXFO_.js} +1 -1
  205. data/public/railwatch/assets/filter-bar-DEv0G_34.js +1 -0
  206. data/public/railwatch/assets/{flamegraph-BNbNIuKT.js → flamegraph-SKD9yxW9.js} +2 -2
  207. data/public/railwatch/assets/frames-CUpyJQDy.js +1 -0
  208. data/public/railwatch/assets/index-2W-ppwap.js +1 -0
  209. data/public/railwatch/assets/index-74ocuU5d.js +1 -0
  210. data/public/railwatch/assets/{index-Zkt4W_CM.js → index-B8NA62Lz.js} +2 -2
  211. data/public/railwatch/assets/index-BBCEkLqp.js +1 -0
  212. data/public/railwatch/assets/index-BDcglJzX.js +1 -0
  213. data/public/railwatch/assets/index-B_KFb9LS.js +1 -0
  214. data/public/railwatch/assets/index-BqK_VKom.js +1 -0
  215. data/public/railwatch/assets/index-BxCXBHP2.js +1 -0
  216. data/public/railwatch/assets/index-C1jc1OoH.js +1 -0
  217. data/public/railwatch/assets/index-C7zREldp.js +1 -0
  218. data/public/railwatch/assets/index-CEetm4oC.js +1 -0
  219. data/public/railwatch/assets/index-CIaZkokF.js +1 -0
  220. data/public/railwatch/assets/index-CNH85TZp.js +1 -0
  221. data/public/railwatch/assets/index-Ce4SB5AB.js +1 -0
  222. data/public/railwatch/assets/index-CiRMSYMn.js +1 -0
  223. data/public/railwatch/assets/index-CjxXgOs-.js +1 -0
  224. data/public/railwatch/assets/index-ClL-PYEV.js +1 -0
  225. data/public/railwatch/assets/index-CteZ6fop.js +1 -0
  226. data/public/railwatch/assets/index-CvqfEExO.js +1 -0
  227. data/public/railwatch/assets/index-CxTLT-Qh.js +1 -0
  228. data/public/railwatch/assets/index-DJd3OUSd.js +1 -0
  229. data/public/railwatch/assets/index-DPD-rLp6.js +1 -0
  230. data/public/railwatch/assets/index-DYK_dps4.js +1 -0
  231. data/public/railwatch/assets/index-DrayARrI.js +1 -0
  232. data/public/railwatch/assets/index-HRXGir-0.js +1 -0
  233. data/public/railwatch/assets/index-UJOUALr_.js +1 -0
  234. data/public/railwatch/assets/index-d-3J7gQ5.js +1 -0
  235. data/public/railwatch/assets/index-oQrvBpOm.js +1 -0
  236. data/public/railwatch/assets/inertia-FoqYZAk8.js +97 -0
  237. data/public/railwatch/assets/json-viewer-CxqMojEm.js +1 -0
  238. data/public/railwatch/assets/klass-DEF-Vat5.js +1 -0
  239. data/public/railwatch/assets/origin-identity-Y6gvfQVR.js +1 -0
  240. data/public/railwatch/assets/{percentile-picker-BxIVblBw.js → percentile-picker-E2aUdLxx.js} +1 -1
  241. data/public/railwatch/assets/relative-time-DcJbkKlN.js +1 -0
  242. data/public/railwatch/assets/{release-health-Cb1tMOOO.js → release-health-C8WSCDKD.js} +1 -1
  243. data/public/railwatch/assets/route-qO5vX1p-.js +1 -0
  244. data/public/railwatch/assets/{segmented-C4oWE73o.js → segmented-BWee8uc9.js} +1 -1
  245. data/public/railwatch/assets/select-G7rNBzv0.js +1 -0
  246. data/public/railwatch/assets/series-chart-C5nXIUc5.js +1 -0
  247. data/public/railwatch/assets/show-BEWisLQw.js +1 -0
  248. data/public/railwatch/assets/show-BEa9PGu1.js +2 -0
  249. data/public/railwatch/assets/{show-gtwKsTsl.js → show-BiT_Aloc.js} +3 -3
  250. data/public/railwatch/assets/show-BpCO49d3.js +1 -0
  251. data/public/railwatch/assets/show-CH7yxt7R.js +1 -0
  252. data/public/railwatch/assets/show-CTO8Lvd2.js +1 -0
  253. data/public/railwatch/assets/show-DnYsDMUt.js +2 -0
  254. data/public/railwatch/assets/show-DyzHM527.js +1 -0
  255. data/public/railwatch/assets/show-Nfpxs8su.js +1 -0
  256. data/public/railwatch/assets/show-ddjUMo9G.js +1 -0
  257. data/public/railwatch/assets/show-ksjbSzDH.js +1 -0
  258. data/public/railwatch/assets/{sort-header-CPM6fhXv.js → sort-header-uOq5VlrB.js} +1 -1
  259. data/public/railwatch/assets/{sparkline-cell-CXCxXPWt.js → sparkline-cell-DgGij0cu.js} +1 -1
  260. data/public/railwatch/assets/stat-BTzNKTm2.js +1 -0
  261. data/public/railwatch/assets/status-badge-BDkOXa-C.js +1 -0
  262. data/public/railwatch/assets/{tenant-path-C4aM_cO0.js → tenant-path-DU0ezKSU.js} +1 -1
  263. data/public/railwatch/assets/{use-clipboard-C1ApSwzA.js → use-clipboard-DoENmlWY.js} +1 -1
  264. data/public/railwatch/assets/{use-live-DWwLg1Nj.js → use-live-CE_fN5_6.js} +1 -1
  265. data/public/railwatch/manifest.json +612 -1591
  266. metadata +242 -117
  267. data/public/railwatch/assets/app-layout-2xAKndVD.js +0 -1
  268. data/public/railwatch/assets/app-wordmark-Bt6XqURo.js +0 -1
  269. data/public/railwatch/assets/appearance-DyCfeh9o.js +0 -1
  270. data/public/railwatch/assets/application-B7h1MIhi.css +0 -1
  271. data/public/railwatch/assets/auth-layout-BuwogWMZ.js +0 -1
  272. data/public/railwatch/assets/badge-Dv7I-ebP.js +0 -1
  273. data/public/railwatch/assets/checkbox-DKbThbxr.js +0 -1
  274. data/public/railwatch/assets/code-CYahYQHZ.js +0 -1
  275. data/public/railwatch/assets/copy-DaQMJWoT.js +0 -1
  276. data/public/railwatch/assets/copy-block-CbJum9s8.js +0 -1
  277. data/public/railwatch/assets/copy-id-JmvLfkmx.js +0 -1
  278. data/public/railwatch/assets/cursor-load-more-C1ylbqqL.js +0 -1
  279. data/public/railwatch/assets/data-table-CVKR2wkq.js +0 -1
  280. data/public/railwatch/assets/edit-BXF5oNYA.js +0 -1
  281. data/public/railwatch/assets/edit-BwSEVrTG.js +0 -8
  282. data/public/railwatch/assets/edit-D5BRDwLT.js +0 -1
  283. data/public/railwatch/assets/empty-state-Bn48Gplm.js +0 -1
  284. data/public/railwatch/assets/env-layout-CKLL8Ds7.js +0 -1
  285. data/public/railwatch/assets/filter-bar-xcbJC93z.js +0 -1
  286. data/public/railwatch/assets/frames-Bk39xfJG.js +0 -1
  287. data/public/railwatch/assets/google-sign-in-button-B5as2ps1.js +0 -1
  288. data/public/railwatch/assets/index-Atp1FTgX.js +0 -1
  289. data/public/railwatch/assets/index-B-FXAo1w.js +0 -1
  290. data/public/railwatch/assets/index-B0xgzHuN.js +0 -1
  291. data/public/railwatch/assets/index-BDVShWMs.js +0 -1
  292. data/public/railwatch/assets/index-BRvRkfob.js +0 -1
  293. data/public/railwatch/assets/index-BVWapDeB.js +0 -1
  294. data/public/railwatch/assets/index-Bd4YpOIO.js +0 -1
  295. data/public/railwatch/assets/index-BeQw69GM.js +0 -1
  296. data/public/railwatch/assets/index-BfitxVBv.js +0 -1
  297. data/public/railwatch/assets/index-C-EwH4lO.js +0 -1
  298. data/public/railwatch/assets/index-C8JIdb3_.js +0 -1
  299. data/public/railwatch/assets/index-CBFCtJAR.js +0 -1
  300. data/public/railwatch/assets/index-CDXlITyG.js +0 -1
  301. data/public/railwatch/assets/index-COd9lT84.js +0 -1
  302. data/public/railwatch/assets/index-C_hQmVTP.js +0 -1
  303. data/public/railwatch/assets/index-CcennT28.js +0 -1
  304. data/public/railwatch/assets/index-CiaeS-n7.js +0 -1
  305. data/public/railwatch/assets/index-CrA7jBTK.js +0 -1
  306. data/public/railwatch/assets/index-D0mKj3Hg.js +0 -1
  307. data/public/railwatch/assets/index-DV5MdmpY.js +0 -1
  308. data/public/railwatch/assets/index-DaE-1xhx.js +0 -1
  309. data/public/railwatch/assets/index-DdVg9LKX.js +0 -1
  310. data/public/railwatch/assets/index-DeLtMWou.js +0 -1
  311. data/public/railwatch/assets/index-DiQ4mqT_.js +0 -1
  312. data/public/railwatch/assets/index-LKgpCzoR.js +0 -1
  313. data/public/railwatch/assets/index-S0NVaLj9.js +0 -1
  314. data/public/railwatch/assets/index-Vj93BTR5.js +0 -1
  315. data/public/railwatch/assets/index-XjXcV-EO.js +0 -1
  316. data/public/railwatch/assets/index-aPCZX-pW.js +0 -1
  317. data/public/railwatch/assets/index-cTzBycCg.js +0 -1
  318. data/public/railwatch/assets/index-dpG-19ZN.js +0 -1
  319. data/public/railwatch/assets/index-gCvuyG1l.js +0 -1
  320. data/public/railwatch/assets/index-o1xqGvLo.js +0 -1
  321. data/public/railwatch/assets/index-w_skgMmK.js +0 -1
  322. data/public/railwatch/assets/inertia-CqnzqPVD.js +0 -97
  323. data/public/railwatch/assets/input-error-ByT28UdP.js +0 -1
  324. data/public/railwatch/assets/json-viewer-Bnu9RsJL.js +0 -1
  325. data/public/railwatch/assets/klass-nLiBV-Az.js +0 -1
  326. data/public/railwatch/assets/label-1nSzpSB6.js +0 -1
  327. data/public/railwatch/assets/layout-D7lE1Doo.js +0 -1
  328. data/public/railwatch/assets/live-dot-UdzOfzJb.js +0 -41
  329. data/public/railwatch/assets/nav-BRCgp2w3.js +0 -1
  330. data/public/railwatch/assets/new-C60G72DR.js +0 -1
  331. data/public/railwatch/assets/new-COyDKOOb.js +0 -1
  332. data/public/railwatch/assets/new-Cd9zPNJ8.js +0 -1
  333. data/public/railwatch/assets/new-DMJGKjmH.js +0 -1
  334. data/public/railwatch/assets/new-Omvf-c9s.js +0 -1
  335. data/public/railwatch/assets/new-vjRzERL3.js +0 -1
  336. data/public/railwatch/assets/onboarding-gUjJQc-6.js +0 -1
  337. data/public/railwatch/assets/origin-identity-M1YUPYS0.js +0 -1
  338. data/public/railwatch/assets/relative-time-DQ4kjowZ.js +0 -1
  339. data/public/railwatch/assets/route-BJkoEm7r.js +0 -1
  340. data/public/railwatch/assets/select-ocdQJdQo.js +0 -1
  341. data/public/railwatch/assets/separator-Ct2UcZ2x.js +0 -1
  342. data/public/railwatch/assets/series-chart-D6upsrSn.js +0 -1
  343. data/public/railwatch/assets/show-B4bwI9x2.js +0 -2
  344. data/public/railwatch/assets/show-BAjfddEn.js +0 -6
  345. data/public/railwatch/assets/show-BLJOyLb_.js +0 -1
  346. data/public/railwatch/assets/show-BoVyMQS6.js +0 -1
  347. data/public/railwatch/assets/show-Bsu1rvnK.js +0 -1
  348. data/public/railwatch/assets/show-C6OiJ6Hr.js +0 -1
  349. data/public/railwatch/assets/show-C8TkpEgm.js +0 -1
  350. data/public/railwatch/assets/show-CBFBiBY2.js +0 -1
  351. data/public/railwatch/assets/show-CEhPVMMT.js +0 -1
  352. data/public/railwatch/assets/show-CV-lG2b6.js +0 -2
  353. data/public/railwatch/assets/show-CX_pab9S.js +0 -2
  354. data/public/railwatch/assets/show-D6lgzL6n.js +0 -1
  355. data/public/railwatch/assets/show-DDVsv-uS.js +0 -1
  356. data/public/railwatch/assets/show-Dwiq-8hA.js +0 -1
  357. data/public/railwatch/assets/show-eKazflRI.js +0 -1
  358. data/public/railwatch/assets/show-yEOrk6Pt.js +0 -1
  359. data/public/railwatch/assets/stat-BNEfE8X1.js +0 -1
  360. data/public/railwatch/assets/status-badge-D0aylrM5.js +0 -1
  361. data/public/railwatch/assets/text-link-CP4lqUi6.js +0 -1
  362. data/public/railwatch/assets/textarea-DzJo2ds4.js +0 -1
  363. data/public/railwatch/assets/timeline-BNcJ9PMc.js +0 -1
  364. data/public/railwatch/assets/transition-BX2M1iaq.js +0 -5
@@ -0,0 +1,324 @@
1
+ import { useEffect, useMemo, useRef, useState } from "react"
2
+
3
+ import { DataTable } from "@/components/railwatch/data-table"
4
+ import { Segmented, SegmentedItem } from "@/components/railwatch/segmented"
5
+ import { Input } from "@/components/ui/input"
6
+ import {
7
+ type FlameNode,
8
+ type FlameRect,
9
+ hottestSelf,
10
+ hottestTotal,
11
+ layout,
12
+ parseCollapsed,
13
+ search,
14
+ } from "@/lib/flamegraph"
15
+ import { count, pct } from "@/lib/format"
16
+ import { cn } from "@/lib/utils"
17
+
18
+ const ROW = 18
19
+ const HOTTEST = 15
20
+
21
+ interface View {
22
+ tree: FlameNode
23
+ zoom: FlameNode[]
24
+ hovered: { rect: FlameRect; x: number; y: number } | null
25
+ }
26
+
27
+ // Frames read "Class#method (path:line)". The gem strips the Rails root
28
+ // from app paths, so an app frame's path starts with app/, lib/ or config/;
29
+ // a gem frame's path starts with the gem's name (activerecord/...), Ruby's
30
+ // own with ruby/ or <internal:, and a C function has no file at all. Only
31
+ // the app's own frames get the warm end of the palette.
32
+ function isAppFrame(name: string): boolean {
33
+ const open = name.lastIndexOf(" (")
34
+ const path = open >= 0 ? name.slice(open + 2) : ""
35
+ return /^(app|lib|config|db|engines)\//.test(path)
36
+ }
37
+
38
+ function hashOf(value: string): number {
39
+ let h = 0
40
+ for (let i = 0; i < value.length; i++) h = (h * 31 + value.charCodeAt(i)) | 0
41
+ return Math.abs(h)
42
+ }
43
+
44
+ // Colour is a stable hash of the frame name so the same frame keeps the same
45
+ // shade between renders and between profiles — warm oranges/ambers for app
46
+ // frames, desaturated slate for everything else.
47
+ function frameColor(name: string): string {
48
+ const h = hashOf(name)
49
+ return isAppFrame(name)
50
+ ? `hsl(${[24, 34, 14, 44, 4][h % 5]} 78% ${52 + (h % 3) * 6}%)`
51
+ : `hsl(${[215, 220, 210][h % 3]} 14% ${58 + (h % 3) * 7}%)`
52
+ }
53
+
54
+ function truncate(name: string, width: number): string {
55
+ const chars = Math.floor((width - 8) / 5.6)
56
+ if (chars < 4) return ""
57
+ return name.length <= chars ? name : name.slice(0, chars - 1) + "…"
58
+ }
59
+
60
+ function Tooltip({
61
+ rect,
62
+ total,
63
+ x,
64
+ y,
65
+ }: {
66
+ rect: FlameRect
67
+ total: number
68
+ x: number
69
+ y: number
70
+ }) {
71
+ return (
72
+ <div
73
+ className="bg-popover text-popover-foreground pointer-events-none absolute z-10 max-w-[min(22rem,90vw)] rounded-md border p-2 shadow-md"
74
+ style={{ left: Math.max(0, x - 8), top: y + 14 }}
75
+ >
76
+ <div className="font-mono text-[11px] break-all">{rect.node.name}</div>
77
+ <div className="text-muted-foreground mt-1 flex flex-wrap gap-x-3 font-mono text-[10px] tabular-nums">
78
+ <span>{count(rect.node.value)} samples</span>
79
+ <span>{pct(rect.node.value, total)} of total</span>
80
+ <span>{pct(rect.node.value, rect.parentValue)} of parent</span>
81
+ {rect.node.self > 0 && <span>{count(rect.node.self)} self</span>}
82
+ </div>
83
+ </div>
84
+ )
85
+ }
86
+
87
+ // An icicle chart (root on top, callees below) over collapsed stacks. Click
88
+ // a frame to zoom into it; the breadcrumb walks back out.
89
+ export function Flamegraph({
90
+ collapsed,
91
+ className,
92
+ }: {
93
+ collapsed: string
94
+ className?: string
95
+ }) {
96
+ const tree = useMemo(() => parseCollapsed(collapsed), [collapsed])
97
+ const [query, setQuery] = useState("")
98
+ const [mode, setMode] = useState<"self" | "total">("self")
99
+ // Zoom path and hover both point at nodes of one particular tree, so they
100
+ // are stored with it: a new profile makes the whole view stale at once,
101
+ // without an effect that resets them a render late.
102
+ const [state, setState] = useState<View>({ tree, zoom: [], hovered: null })
103
+ const view = state.tree === tree ? state : { tree, zoom: [], hovered: null }
104
+ const zoom = view.zoom
105
+
106
+ const containerRef = useRef<HTMLDivElement>(null)
107
+ const [width, setWidth] = useState(0)
108
+
109
+ useEffect(() => {
110
+ const el = containerRef.current
111
+ if (!el) return
112
+ setWidth(el.clientWidth)
113
+ const observer = new ResizeObserver(([entry]) =>
114
+ setWidth(entry.contentRect.width),
115
+ )
116
+ observer.observe(el)
117
+ return () => observer.disconnect()
118
+ }, [])
119
+
120
+ const root = zoom[zoom.length - 1] ?? tree
121
+ const rects = useMemo(() => layout(root, width), [root, width])
122
+ const matches = useMemo(() => search(tree, query), [tree, query])
123
+ const hottest = useMemo(
124
+ () =>
125
+ mode === "self"
126
+ ? hottestSelf(root, HOTTEST)
127
+ : hottestTotal(root, HOTTEST),
128
+ [root, mode],
129
+ )
130
+
131
+ const depth = rects.reduce((max, r) => Math.max(max, r.depth), 0)
132
+ const searching = matches.size > 0
133
+
134
+ if (tree.value === 0) {
135
+ return (
136
+ <p className="text-muted-foreground py-6 text-center text-sm">
137
+ This profile recorded no samples.
138
+ </p>
139
+ )
140
+ }
141
+
142
+ return (
143
+ <div className={cn("space-y-3", className)}>
144
+ <div className="flex flex-wrap items-center gap-2">
145
+ <Input
146
+ value={query}
147
+ onChange={(e) => setQuery(e.target.value)}
148
+ placeholder="Search frames"
149
+ className="h-8 max-w-56 font-mono text-xs"
150
+ aria-label="Search frames"
151
+ />
152
+ {query !== "" && (
153
+ <span className="text-muted-foreground font-mono text-[11px]">
154
+ {matches.size} match{matches.size === 1 ? "" : "es"}
155
+ </span>
156
+ )}
157
+ <div className="ml-auto flex flex-wrap items-center gap-1 font-mono text-[11px]">
158
+ {[tree, ...zoom].map((node, i) => (
159
+ <span key={node.id} className="flex items-center gap-1">
160
+ {i > 0 && <span className="text-muted-foreground">/</span>}
161
+ <button
162
+ type="button"
163
+ onClick={() => setState({ ...view, zoom: zoom.slice(0, i) })}
164
+ disabled={i === zoom.length}
165
+ className={cn(
166
+ "max-w-40 truncate",
167
+ i === zoom.length
168
+ ? "text-foreground font-semibold"
169
+ : "text-muted-foreground hover:underline",
170
+ )}
171
+ >
172
+ {node.name}
173
+ </button>
174
+ </span>
175
+ ))}
176
+ </div>
177
+ </div>
178
+
179
+ <div
180
+ ref={containerRef}
181
+ className="bg-muted/20 relative overflow-hidden rounded-lg border"
182
+ onMouseLeave={() => setState({ ...view, hovered: null })}
183
+ >
184
+ <svg
185
+ width={width}
186
+ height={(depth + 1) * ROW}
187
+ role="img"
188
+ aria-label={`Flamegraph of ${tree.value} samples`}
189
+ >
190
+ {rects.map((r) => {
191
+ const label = truncate(r.node.name, r.width)
192
+ return (
193
+ <g
194
+ key={r.id}
195
+ onClick={() =>
196
+ setState({
197
+ ...view,
198
+ zoom: [...zoom, ...pathTo(root, r.node)],
199
+ })
200
+ }
201
+ onMouseMove={(e) => {
202
+ const box = containerRef.current?.getBoundingClientRect()
203
+ setState({
204
+ ...view,
205
+ hovered: {
206
+ rect: r,
207
+ x: e.clientX - (box?.left ?? 0),
208
+ y: e.clientY - (box?.top ?? 0),
209
+ },
210
+ })
211
+ }}
212
+ className="cursor-pointer"
213
+ >
214
+ <rect
215
+ x={r.x}
216
+ y={r.depth * ROW}
217
+ width={Math.max(0, r.width - 1)}
218
+ height={ROW - 1}
219
+ rx={2}
220
+ fill={frameColor(r.node.name)}
221
+ opacity={
222
+ searching
223
+ ? matches.has(r.id)
224
+ ? 1
225
+ : 0.18
226
+ : view.hovered?.rect.id === r.id
227
+ ? 1
228
+ : 0.88
229
+ }
230
+ stroke={matches.has(r.id) ? "currentColor" : "none"}
231
+ strokeWidth={1}
232
+ />
233
+ {label !== "" && (
234
+ <text
235
+ x={r.x + 4}
236
+ y={r.depth * ROW + ROW / 2}
237
+ dominantBaseline="central"
238
+ className="pointer-events-none fill-black/85 font-mono text-[10px]"
239
+ >
240
+ {label}
241
+ </text>
242
+ )}
243
+ </g>
244
+ )
245
+ })}
246
+ </svg>
247
+ {view.hovered && (
248
+ <Tooltip
249
+ rect={view.hovered.rect}
250
+ total={tree.value}
251
+ x={view.hovered.x}
252
+ y={view.hovered.y}
253
+ />
254
+ )}
255
+ </div>
256
+
257
+ <div className="flex flex-wrap items-center justify-between gap-2">
258
+ <h3 className="label-caps">Hottest frames</h3>
259
+ <Segmented>
260
+ <SegmentedItem
261
+ active={mode === "self"}
262
+ onClick={() => setMode("self")}
263
+ >
264
+ Self
265
+ </SegmentedItem>
266
+ <SegmentedItem
267
+ active={mode === "total"}
268
+ onClick={() => setMode("total")}
269
+ >
270
+ Total
271
+ </SegmentedItem>
272
+ </Segmented>
273
+ </div>
274
+ <DataTable
275
+ rows={hottest}
276
+ rowKey={(f) => f.name}
277
+ empty="No frames in this view."
278
+ columns={[
279
+ {
280
+ key: "name",
281
+ header: "Frame",
282
+ className: "max-w-0",
283
+ cell: (f) => (
284
+ <span className="block truncate font-mono text-xs" title={f.name}>
285
+ {f.name}
286
+ </span>
287
+ ),
288
+ },
289
+ {
290
+ key: "self",
291
+ header: "Self",
292
+ align: "right",
293
+ cell: (f) => (
294
+ <span className={mode === "self" ? "font-semibold" : ""}>
295
+ {count(f.self)}
296
+ </span>
297
+ ),
298
+ },
299
+ {
300
+ key: "total",
301
+ header: "Total",
302
+ align: "right",
303
+ cell: (f) => (
304
+ <span className={mode === "total" ? "font-semibold" : ""}>
305
+ {count(f.total)}
306
+ </span>
307
+ ),
308
+ },
309
+ ]}
310
+ />
311
+ </div>
312
+ )
313
+ }
314
+
315
+ // The chain of nodes from the current root down to `target`, so clicking a
316
+ // frame deep in the graph pushes every step onto the breadcrumb.
317
+ function pathTo(from: FlameNode, target: FlameNode): FlameNode[] {
318
+ if (from === target) return []
319
+ for (const child of from.children) {
320
+ const rest = pathTo(child, target)
321
+ if (rest.length > 0 || child === target) return [child, ...rest]
322
+ }
323
+ return []
324
+ }
@@ -0,0 +1,295 @@
1
+ import { Fragment, useState } from "react"
2
+
3
+ import { SourceLink } from "@/components/railwatch/source-link"
4
+ import { Badge } from "@/components/ui/badge"
5
+ import { cn } from "@/lib/utils"
6
+ import type { ExceptionDetail, Frame, TimelineEntry } from "@/types"
7
+
8
+ // A hashed build asset -- "assets/index-Bq1x9K2v.js", what a browser frame
9
+ // points at in production. It is the app's own code, but not at any path
10
+ // that exists in the repository, so there is nothing to link it to until
11
+ // Railwatch can resolve it through a source map (docs/roadmap.md); until then
12
+ // it is shown exactly as the browser named it.
13
+ const HASHED_ASSET = /-[A-Za-z0-9_-]{8,}\.(?:js|mjs|cjs|css)$/
14
+
15
+ function FrameRow({
16
+ frame,
17
+ deploy,
18
+ open,
19
+ onToggle,
20
+ }: {
21
+ frame: Frame
22
+ deploy?: string | null
23
+ open: boolean
24
+ onToggle: () => void
25
+ }) {
26
+ const lines = frame.code ? Object.entries(frame.code) : []
27
+ const linkable = frame.in_app && !HASHED_ASSET.test(frame.file)
28
+ return (
29
+ <div
30
+ className={cn(
31
+ "border-b last:border-b-0",
32
+ frame.in_app ? "bg-background" : "bg-muted/40",
33
+ )}
34
+ >
35
+ <div className="hover:bg-muted/60 flex w-full items-center gap-2 px-3 py-1.5 font-mono text-xs">
36
+ {linkable && (
37
+ <SourceLink
38
+ location={`${frame.file}:${frame.line}`}
39
+ deploy={deploy}
40
+ />
41
+ )}
42
+ <button
43
+ type="button"
44
+ onClick={onToggle}
45
+ className="flex min-w-0 flex-1 items-center gap-2 text-left"
46
+ >
47
+ {!linkable && (
48
+ <span className="text-muted-foreground truncate">
49
+ {frame.file}:{frame.line}
50
+ </span>
51
+ )}
52
+ <span className="text-muted-foreground ml-auto truncate">
53
+ in {frame.function}
54
+ </span>
55
+ {frame.in_app && (
56
+ <Badge variant="outline" className="shrink-0 text-[10px]">
57
+ app
58
+ </Badge>
59
+ )}
60
+ </button>
61
+ </div>
62
+ {open && lines.length > 0 && (
63
+ <pre className="bg-muted/60 overflow-x-auto px-3 py-2 font-mono text-xs leading-5">
64
+ {lines.map(([n, src]) => (
65
+ <div
66
+ key={n}
67
+ className={cn(
68
+ "flex gap-3 border-l-2 border-transparent pl-2",
69
+ Number(n) === frame.line &&
70
+ "border-destructive bg-destructive/10 font-semibold",
71
+ )}
72
+ >
73
+ <span className="text-muted-foreground w-10 shrink-0 text-right select-none">
74
+ {n}
75
+ </span>
76
+ <span className="whitespace-pre">{src}</span>
77
+ </div>
78
+ ))}
79
+ </pre>
80
+ )}
81
+ </div>
82
+ )
83
+ }
84
+
85
+ export function Frames({
86
+ frames,
87
+ deploy,
88
+ }: {
89
+ frames: Frame[]
90
+ deploy?: string | null
91
+ }) {
92
+ const appIndexes = frames.flatMap((f, i) => (f.in_app ? [i] : []))
93
+ const [open, setOpen] = useState<Set<number>>(
94
+ new Set(appIndexes.length > 0 ? appIndexes : [0]),
95
+ )
96
+ const [showAll, setShowAll] = useState(false)
97
+ const visible = showAll ? frames : frames.filter((f, i) => f.in_app || i < 3)
98
+
99
+ function toggle(idx: number) {
100
+ setOpen((prev) => {
101
+ const next = new Set(prev)
102
+ if (next.has(idx)) next.delete(idx)
103
+ else next.add(idx)
104
+ return next
105
+ })
106
+ }
107
+
108
+ return (
109
+ <div className="overflow-hidden rounded-lg border">
110
+ {visible.map((f) => {
111
+ const idx = frames.indexOf(f)
112
+ return (
113
+ <FrameRow
114
+ key={idx}
115
+ frame={f}
116
+ deploy={deploy}
117
+ open={open.has(idx)}
118
+ onToggle={() => toggle(idx)}
119
+ />
120
+ )
121
+ })}
122
+ {visible.length < frames.length && (
123
+ <button
124
+ type="button"
125
+ className="text-muted-foreground w-full px-3 py-1.5 text-left text-xs hover:underline"
126
+ onClick={() => setShowAll(true)}
127
+ >
128
+ Show {frames.length - visible.length} framework frames
129
+ </button>
130
+ )}
131
+ </div>
132
+ )
133
+ }
134
+
135
+ export function ExceptionCard({
136
+ exception,
137
+ deploy,
138
+ }: {
139
+ exception: ExceptionDetail
140
+ deploy?: string | null
141
+ }) {
142
+ return (
143
+ <div className="space-y-3">
144
+ <div>
145
+ <div className="flex flex-wrap items-center gap-2">
146
+ <span className="font-mono text-sm font-semibold">
147
+ {exception.class_name}
148
+ </span>
149
+ <Badge variant={exception.handled ? "secondary" : "destructive"}>
150
+ {exception.handled ? "handled" : "unhandled"}
151
+ </Badge>
152
+ {exception.severity && (
153
+ <Badge variant="outline">{exception.severity}</Badge>
154
+ )}
155
+ {exception.source && (
156
+ <span className="text-muted-foreground font-mono text-xs">
157
+ {exception.source}
158
+ </span>
159
+ )}
160
+ </div>
161
+ <p className="mt-1 font-mono text-sm break-words">
162
+ {exception.message}
163
+ </p>
164
+ {exception.cause && (
165
+ <p className="text-muted-foreground mt-1 font-mono text-xs">
166
+ caused by {exception.cause.class}: {exception.cause.message}
167
+ </p>
168
+ )}
169
+ </div>
170
+ <Frames frames={exception.frames} deploy={deploy} />
171
+ {exception.locals && Object.keys(exception.locals).length > 0 && (
172
+ <div className="overflow-hidden rounded-lg border">
173
+ <div className="label-caps border-b px-3 py-1.5">
174
+ Local variables at the raise site
175
+ </div>
176
+ <dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 px-3 py-2 font-mono text-xs">
177
+ {Object.entries(exception.locals).map(([name, value]) => (
178
+ <Fragment key={name}>
179
+ <dt className="text-muted-foreground">{name}</dt>
180
+ <dd className="break-all">{value}</dd>
181
+ </Fragment>
182
+ ))}
183
+ </dl>
184
+ </div>
185
+ )}
186
+ {exception.context && exception.context !== "{}" && (
187
+ <pre className="bg-muted/60 overflow-x-auto rounded-lg p-3 font-mono text-xs">
188
+ {exception.context}
189
+ </pre>
190
+ )}
191
+ </div>
192
+ )
193
+ }
194
+
195
+ const crumbColors: Record<string, string> = {
196
+ query: "bg-sky-500",
197
+ transaction: "bg-sky-300",
198
+ cache_event: "bg-violet-500",
199
+ outgoing_request: "bg-orange-500",
200
+ mail: "bg-pink-500",
201
+ broadcast: "bg-teal-500",
202
+ enqueued_job: "bg-amber-500",
203
+ view_render: "bg-emerald-500",
204
+ storage_op: "bg-indigo-500",
205
+ exception: "bg-red-500",
206
+ log: "bg-neutral-400",
207
+ }
208
+
209
+ // Sentry-style breadcrumbs: what the execution did in the run-up to the
210
+ // exception, oldest first, each with its offset from the start.
211
+ export function Breadcrumbs({ entries }: { entries: TimelineEntry[] }) {
212
+ if (entries.length === 0) return null
213
+ return (
214
+ <div className="overflow-hidden rounded-lg border">
215
+ <div className="label-caps border-b px-3 py-1.5">
216
+ Breadcrumbs · {entries.length} events before the exception
217
+ </div>
218
+ <ol className="divide-y">
219
+ {entries.map((e) => (
220
+ <li
221
+ key={`${e.type}-${e.id}`}
222
+ className="grid grid-cols-[3.5rem_5rem_1fr_4rem] items-center gap-2 px-3 py-1 font-mono text-xs"
223
+ >
224
+ <span className="text-muted-foreground tabular-nums">
225
+ +{e.offset.toFixed(1)}ms
226
+ </span>
227
+ <span className="text-muted-foreground flex items-center gap-1.5 truncate">
228
+ <span
229
+ className={cn(
230
+ "inline-block size-1.5 shrink-0 rounded-full",
231
+ crumbColors[e.type] ?? "bg-neutral-500",
232
+ )}
233
+ />
234
+ {e.type.replace("_", " ")}
235
+ </span>
236
+ <span className="truncate">{e.label}</span>
237
+ <span className="text-muted-foreground text-right tabular-nums">
238
+ {e.duration != null ? `${e.duration.toFixed(1)}ms` : ""}
239
+ </span>
240
+ </li>
241
+ ))}
242
+ </ol>
243
+ </div>
244
+ )
245
+ }
246
+
247
+ export interface BrowserCrumb {
248
+ at: number
249
+ kind: string
250
+ text: string
251
+ }
252
+
253
+ const browserCrumbColors: Record<string, string> = {
254
+ console: "bg-red-500",
255
+ click: "bg-violet-500",
256
+ navigate: "bg-sky-500",
257
+ }
258
+
259
+ // The browser's half of the same idea: what the user did in the run-up to a
260
+ // JavaScript error -- console errors, clicks, Inertia navigations -- offset
261
+ // from the first of them, since a browser error has no execution whose
262
+ // timeline would mean anything (its execution is the beacon that carried it).
263
+ export function BrowserBreadcrumbs({ crumbs }: { crumbs: BrowserCrumb[] }) {
264
+ if (crumbs.length === 0) return null
265
+ const start = crumbs[0].at
266
+ return (
267
+ <div className="overflow-hidden rounded-lg border">
268
+ <div className="label-caps border-b px-3 py-1.5">
269
+ Breadcrumbs · {crumbs.length} events before the error
270
+ </div>
271
+ <ol className="divide-y">
272
+ {crumbs.map((c, i) => (
273
+ <li
274
+ key={`${c.at}-${c.kind}-${i}`}
275
+ className="grid grid-cols-[3.5rem_5rem_1fr] items-center gap-2 px-3 py-1 font-mono text-xs"
276
+ >
277
+ <span className="text-muted-foreground tabular-nums">
278
+ +{((c.at - start) / 1000).toFixed(1)}s
279
+ </span>
280
+ <span className="text-muted-foreground flex items-center gap-1.5 truncate">
281
+ <span
282
+ className={cn(
283
+ "inline-block size-1.5 shrink-0 rounded-full",
284
+ browserCrumbColors[c.kind] ?? "bg-neutral-500",
285
+ )}
286
+ />
287
+ {c.kind}
288
+ </span>
289
+ <span className="truncate">{c.text}</span>
290
+ </li>
291
+ ))}
292
+ </ol>
293
+ </div>
294
+ )
295
+ }