railwatch 0.6.1 → 0.8.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 (349) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -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/app/models/railwatch/filter_query.rb +5 -5
  171. data/app/models/railwatch/telemetry/execution.rb +47 -0
  172. data/app/models/railwatch/telemetry/rollup.rb +73 -9
  173. data/db/railwatch_telemetry_migrate/20260922000000_add_covering_indexes_for_dashboard_aggregates.rb +34 -0
  174. data/lib/railwatch/version.rb +1 -1
  175. data/lib/railwatch.rb +8 -3
  176. data/public/railwatch/assets/app-layout-LvxN9xSS.js +41 -0
  177. data/public/railwatch/assets/application-Cc5v_Vk9.css +1 -0
  178. data/public/railwatch/assets/{arrow-up-CLQ-7heQ.js → arrow-up-DG01k9AL.js} +1 -1
  179. data/public/railwatch/assets/{braces-rSYydpLY.js → braces-C3w7pfxS.js} +1 -1
  180. data/public/railwatch/assets/{card-DPjFKfen.js → card-Dpwlr0uY.js} +1 -1
  181. data/public/railwatch/assets/{chart-DWh7l8yM.js → chart-DJHv3R7W.js} +1 -1
  182. data/public/railwatch/assets/{chart-hover-CfoZUY4J.js → chart-hover-CEH-DtXO.js} +1 -1
  183. data/public/railwatch/assets/{chart-panel-CC49WTQL.js → chart-panel-CUdIzJvk.js} +1 -1
  184. data/public/railwatch/assets/code-BRZprwun.js +1 -0
  185. data/public/railwatch/assets/copy-id-CwhGGiwF.js +1 -0
  186. data/public/railwatch/assets/cursor-load-more-uF1_955Q.js +1 -0
  187. data/public/railwatch/assets/data-table-DYi-N0hm.js +1 -0
  188. data/public/railwatch/assets/env-layout-HdcOaPPX.js +1 -0
  189. data/public/railwatch/assets/{execution-path-FAuIzOXB.js → execution-path-CElXXFO_.js} +1 -1
  190. data/public/railwatch/assets/filter-bar-DEv0G_34.js +1 -0
  191. data/public/railwatch/assets/{flamegraph-EaGkP2NT.js → flamegraph-SKD9yxW9.js} +2 -2
  192. data/public/railwatch/assets/frames-CUpyJQDy.js +1 -0
  193. data/public/railwatch/assets/index-2W-ppwap.js +1 -0
  194. data/public/railwatch/assets/index-74ocuU5d.js +1 -0
  195. data/public/railwatch/assets/{index-C8Cggnbw.js → index-B8NA62Lz.js} +2 -2
  196. data/public/railwatch/assets/index-BBCEkLqp.js +1 -0
  197. data/public/railwatch/assets/index-BDcglJzX.js +1 -0
  198. data/public/railwatch/assets/index-B_KFb9LS.js +1 -0
  199. data/public/railwatch/assets/index-BqK_VKom.js +1 -0
  200. data/public/railwatch/assets/index-BxCXBHP2.js +1 -0
  201. data/public/railwatch/assets/index-C1jc1OoH.js +1 -0
  202. data/public/railwatch/assets/index-C7zREldp.js +1 -0
  203. data/public/railwatch/assets/index-CEetm4oC.js +1 -0
  204. data/public/railwatch/assets/index-CIaZkokF.js +1 -0
  205. data/public/railwatch/assets/index-CNH85TZp.js +1 -0
  206. data/public/railwatch/assets/index-Ce4SB5AB.js +1 -0
  207. data/public/railwatch/assets/index-CiRMSYMn.js +1 -0
  208. data/public/railwatch/assets/index-CjxXgOs-.js +1 -0
  209. data/public/railwatch/assets/index-ClL-PYEV.js +1 -0
  210. data/public/railwatch/assets/index-CteZ6fop.js +1 -0
  211. data/public/railwatch/assets/index-CvqfEExO.js +1 -0
  212. data/public/railwatch/assets/index-CxTLT-Qh.js +1 -0
  213. data/public/railwatch/assets/index-DJd3OUSd.js +1 -0
  214. data/public/railwatch/assets/index-DPD-rLp6.js +1 -0
  215. data/public/railwatch/assets/index-DYK_dps4.js +1 -0
  216. data/public/railwatch/assets/index-DrayARrI.js +1 -0
  217. data/public/railwatch/assets/index-HRXGir-0.js +1 -0
  218. data/public/railwatch/assets/index-UJOUALr_.js +1 -0
  219. data/public/railwatch/assets/index-d-3J7gQ5.js +1 -0
  220. data/public/railwatch/assets/index-oQrvBpOm.js +1 -0
  221. data/public/railwatch/assets/inertia-FoqYZAk8.js +97 -0
  222. data/public/railwatch/assets/json-viewer-CxqMojEm.js +1 -0
  223. data/public/railwatch/assets/klass-DEF-Vat5.js +1 -0
  224. data/public/railwatch/assets/origin-identity-Y6gvfQVR.js +1 -0
  225. data/public/railwatch/assets/{percentile-picker-CeQgllxD.js → percentile-picker-E2aUdLxx.js} +1 -1
  226. data/public/railwatch/assets/relative-time-DcJbkKlN.js +1 -0
  227. data/public/railwatch/assets/{release-health-uP-GF3_V.js → release-health-C8WSCDKD.js} +1 -1
  228. data/public/railwatch/assets/route-qO5vX1p-.js +1 -0
  229. data/public/railwatch/assets/{segmented-BeIe4uqk.js → segmented-BWee8uc9.js} +1 -1
  230. data/public/railwatch/assets/select-G7rNBzv0.js +1 -0
  231. data/public/railwatch/assets/series-chart-C5nXIUc5.js +1 -0
  232. data/public/railwatch/assets/show-BEWisLQw.js +1 -0
  233. data/public/railwatch/assets/show-BEa9PGu1.js +2 -0
  234. data/public/railwatch/assets/{show-YsNpqbcI.js → show-BiT_Aloc.js} +3 -3
  235. data/public/railwatch/assets/show-BpCO49d3.js +1 -0
  236. data/public/railwatch/assets/show-CH7yxt7R.js +1 -0
  237. data/public/railwatch/assets/show-CTO8Lvd2.js +1 -0
  238. data/public/railwatch/assets/show-DnYsDMUt.js +2 -0
  239. data/public/railwatch/assets/show-DyzHM527.js +1 -0
  240. data/public/railwatch/assets/show-Nfpxs8su.js +1 -0
  241. data/public/railwatch/assets/show-ddjUMo9G.js +1 -0
  242. data/public/railwatch/assets/show-ksjbSzDH.js +1 -0
  243. data/public/railwatch/assets/{sort-header-mEJUM3Av.js → sort-header-uOq5VlrB.js} +1 -1
  244. data/public/railwatch/assets/{sparkline-cell-Dwg7awwh.js → sparkline-cell-DgGij0cu.js} +1 -1
  245. data/public/railwatch/assets/stat-BTzNKTm2.js +1 -0
  246. data/public/railwatch/assets/status-badge-BDkOXa-C.js +1 -0
  247. data/public/railwatch/assets/{tenant-path-CQoP-BeF.js → tenant-path-DU0ezKSU.js} +1 -1
  248. data/public/railwatch/assets/{use-clipboard-DNefo-ky.js → use-clipboard-DoENmlWY.js} +1 -1
  249. data/public/railwatch/assets/{use-live-DMTuhKfB.js → use-live-CE_fN5_6.js} +1 -1
  250. data/public/railwatch/manifest.json +612 -1591
  251. metadata +243 -117
  252. data/public/railwatch/assets/app-layout-Zc0v-hYh.js +0 -1
  253. data/public/railwatch/assets/app-wordmark-BA_60AVb.js +0 -1
  254. data/public/railwatch/assets/appearance-CcfP9tZ7.js +0 -1
  255. data/public/railwatch/assets/application-BrN3Sz94.css +0 -1
  256. data/public/railwatch/assets/auth-layout-C05gEBIQ.js +0 -1
  257. data/public/railwatch/assets/badge-DRae8XwK.js +0 -1
  258. data/public/railwatch/assets/checkbox-DPkLUiwM.js +0 -1
  259. data/public/railwatch/assets/code-CLmYS6FU.js +0 -1
  260. data/public/railwatch/assets/copy-DaQMJWoT.js +0 -1
  261. data/public/railwatch/assets/copy-block-CGcXxp8J.js +0 -1
  262. data/public/railwatch/assets/copy-id-vBYHQwxg.js +0 -1
  263. data/public/railwatch/assets/cursor-load-more-Ddwxe5dZ.js +0 -1
  264. data/public/railwatch/assets/data-table-CzKTEE-O.js +0 -1
  265. data/public/railwatch/assets/edit-B1kmWkzd.js +0 -8
  266. data/public/railwatch/assets/edit-D9cx4pbG.js +0 -1
  267. data/public/railwatch/assets/edit-yv6j9p-V.js +0 -1
  268. data/public/railwatch/assets/empty-state-CW4wclK_.js +0 -1
  269. data/public/railwatch/assets/env-layout-Kz7wks1x.js +0 -1
  270. data/public/railwatch/assets/filter-bar-CJDjWFib.js +0 -1
  271. data/public/railwatch/assets/frames-zZuIaNH9.js +0 -1
  272. data/public/railwatch/assets/google-sign-in-button-2_zgbgVy.js +0 -1
  273. data/public/railwatch/assets/index-B49SWz7K.js +0 -1
  274. data/public/railwatch/assets/index-BHlY4wKe.js +0 -1
  275. data/public/railwatch/assets/index-BZpPtyFY.js +0 -1
  276. data/public/railwatch/assets/index-BeVK6jCL.js +0 -1
  277. data/public/railwatch/assets/index-BfbSo01U.js +0 -1
  278. data/public/railwatch/assets/index-BfgncAv6.js +0 -1
  279. data/public/railwatch/assets/index-BhNszK1k.js +0 -1
  280. data/public/railwatch/assets/index-Bu01uWvw.js +0 -1
  281. data/public/railwatch/assets/index-C1s_hK3p.js +0 -1
  282. data/public/railwatch/assets/index-CBip6V4z.js +0 -1
  283. data/public/railwatch/assets/index-CMJGss5R.js +0 -1
  284. data/public/railwatch/assets/index-CRo3yK20.js +0 -1
  285. data/public/railwatch/assets/index-CWB_p2J8.js +0 -1
  286. data/public/railwatch/assets/index-CWHpndGc.js +0 -1
  287. data/public/railwatch/assets/index-Ca_S4Sc3.js +0 -1
  288. data/public/railwatch/assets/index-CanPDDOa.js +0 -1
  289. data/public/railwatch/assets/index-Cie90Yat.js +0 -1
  290. data/public/railwatch/assets/index-CiepQ_pR.js +0 -1
  291. data/public/railwatch/assets/index-Cm1uCIGN.js +0 -1
  292. data/public/railwatch/assets/index-CzitnnSC.js +0 -1
  293. data/public/railwatch/assets/index-DEUfClv3.js +0 -1
  294. data/public/railwatch/assets/index-DJKwo-mI.js +0 -1
  295. data/public/railwatch/assets/index-DK6y0YHp.js +0 -1
  296. data/public/railwatch/assets/index-DMNPpLH9.js +0 -1
  297. data/public/railwatch/assets/index-DZO1mSfX.js +0 -1
  298. data/public/railwatch/assets/index-Db5wj3M5.js +0 -1
  299. data/public/railwatch/assets/index-Dcy5WktB.js +0 -1
  300. data/public/railwatch/assets/index-DvG0-7Lx.js +0 -1
  301. data/public/railwatch/assets/index-Dvj1wuka.js +0 -1
  302. data/public/railwatch/assets/index-KyZX46qX.js +0 -1
  303. data/public/railwatch/assets/index-Ze-KP-sl.js +0 -1
  304. data/public/railwatch/assets/index-mhRbWLBM.js +0 -1
  305. data/public/railwatch/assets/index-x099JL5f.js +0 -1
  306. data/public/railwatch/assets/index-x28zb_nf.js +0 -1
  307. data/public/railwatch/assets/inertia-Cuyz2ZHO.js +0 -97
  308. data/public/railwatch/assets/input-error-hog6gGxg.js +0 -1
  309. data/public/railwatch/assets/json-viewer-DH2W9HXf.js +0 -1
  310. data/public/railwatch/assets/klass-DHbDelLk.js +0 -1
  311. data/public/railwatch/assets/label-DdCBgiUn.js +0 -1
  312. data/public/railwatch/assets/layout-Cueyl7c5.js +0 -1
  313. data/public/railwatch/assets/live-dot-BZgYTYdt.js +0 -41
  314. data/public/railwatch/assets/nav-BSSGObDZ.js +0 -1
  315. data/public/railwatch/assets/new-B8FSb8Bl.js +0 -1
  316. data/public/railwatch/assets/new-C39_v2Ll.js +0 -1
  317. data/public/railwatch/assets/new-DVOPwaC5.js +0 -1
  318. data/public/railwatch/assets/new-Dd40nvJR.js +0 -1
  319. data/public/railwatch/assets/new-SxnYe1SE.js +0 -1
  320. data/public/railwatch/assets/new-eP5vKD3Y.js +0 -1
  321. data/public/railwatch/assets/onboarding-CUpZl5KB.js +0 -1
  322. data/public/railwatch/assets/origin-identity-BYt2uiuo.js +0 -1
  323. data/public/railwatch/assets/relative-time-D5UbF4oO.js +0 -1
  324. data/public/railwatch/assets/route-DQAY8JEr.js +0 -1
  325. data/public/railwatch/assets/select-2R16457Z.js +0 -1
  326. data/public/railwatch/assets/separator-D5I0UCB5.js +0 -1
  327. data/public/railwatch/assets/series-chart-gnrzhmM6.js +0 -1
  328. data/public/railwatch/assets/show-2BkeNRUC.js +0 -2
  329. data/public/railwatch/assets/show-B0X1hRQH.js +0 -1
  330. data/public/railwatch/assets/show-BKUV5l5q.js +0 -1
  331. data/public/railwatch/assets/show-BQJD_CsF.js +0 -6
  332. data/public/railwatch/assets/show-BhmD6Sxp.js +0 -1
  333. data/public/railwatch/assets/show-C3KcnVo2.js +0 -1
  334. data/public/railwatch/assets/show-C95cHd06.js +0 -2
  335. data/public/railwatch/assets/show-CdB4uVQS.js +0 -1
  336. data/public/railwatch/assets/show-CoCqcVIp.js +0 -1
  337. data/public/railwatch/assets/show-D2LqjEsU.js +0 -1
  338. data/public/railwatch/assets/show-DNpnKyTh.js +0 -1
  339. data/public/railwatch/assets/show-DOlTxbig.js +0 -1
  340. data/public/railwatch/assets/show-DQCkttL8.js +0 -1
  341. data/public/railwatch/assets/show-DcxesipC.js +0 -1
  342. data/public/railwatch/assets/show-IGJoc_X0.js +0 -1
  343. data/public/railwatch/assets/show-qNV6H8SH.js +0 -2
  344. data/public/railwatch/assets/stat-ZtxGU8lE.js +0 -1
  345. data/public/railwatch/assets/status-badge-CH5P-Xkj.js +0 -1
  346. data/public/railwatch/assets/text-link-DHJ8BXx5.js +0 -1
  347. data/public/railwatch/assets/textarea-BeQtQyl5.js +0 -1
  348. data/public/railwatch/assets/timeline-CaKQVu48.js +0 -1
  349. data/public/railwatch/assets/transition-ksDpqhKJ.js +0 -5
@@ -0,0 +1,9 @@
1
+ import type { FlashData, SharedProps } from "@/types"
2
+
3
+ declare module "@inertiajs/core" {
4
+ export interface InertiaConfig {
5
+ sharedPageProps: SharedProps
6
+ flashDataType: FlashData
7
+ errorValueType: string[]
8
+ }
9
+ }
@@ -0,0 +1,288 @@
1
+ import type { LucideIcon } from "lucide-react"
2
+
3
+ export interface Auth {
4
+ user: User
5
+ session: Pick<Session, "id" | "recently_authenticated">
6
+ }
7
+
8
+ export interface BreadcrumbItem {
9
+ title: string
10
+ href: string
11
+ }
12
+
13
+ export interface NavItem {
14
+ title: string
15
+ href: string
16
+ icon?: LucideIcon | null
17
+ isActive?: boolean
18
+ }
19
+
20
+ export interface FlashData {
21
+ alert?: string
22
+ warning?: string
23
+ notice?: string
24
+ }
25
+
26
+ export interface AccountSummary {
27
+ id: number
28
+ name: string
29
+ slug: string
30
+ plan: string
31
+ }
32
+
33
+ export interface EnvironmentSummary {
34
+ id: number
35
+ name: string
36
+ slug: string
37
+ last_seen_at: string | null
38
+ paused: boolean
39
+ }
40
+
41
+ export interface ApplicationSummary {
42
+ id: number
43
+ name: string
44
+ slug: string
45
+ issue_prefix: string
46
+ environments: EnvironmentSummary[]
47
+ }
48
+
49
+ export interface EnvironmentContext {
50
+ id: number
51
+ name: string
52
+ slug: string
53
+ application_id: number
54
+ application_name: string
55
+ issue_prefix: string
56
+ last_seen_at: string | null
57
+ paused: boolean
58
+ token_prefix: string
59
+ repository_url: string | null
60
+ default_branch: string
61
+ }
62
+
63
+ export type Window = "1h" | "6h" | "24h" | "7d" | "30d" | "custom"
64
+
65
+ // Chart bucket width; the server offers the subset that fits the window.
66
+ export type Step = "1m" | "5m" | "15m" | "1h" | "6h" | "1d"
67
+
68
+ export interface WindowRange {
69
+ from: string
70
+ to: string
71
+ }
72
+
73
+ export type TelemetryTimeParams =
74
+ | { window: Exclude<Window, "custom">; from?: never; to?: never }
75
+ | { window?: never; from: string; to: string }
76
+
77
+ export interface CursorMeta {
78
+ limit: number
79
+ next_cursor: string | null
80
+ has_more: boolean
81
+ }
82
+
83
+ export interface SharedProps {
84
+ auth: Auth
85
+ account: AccountSummary | null
86
+ accounts: { id: number; name: string }[]
87
+ applications: ApplicationSummary[]
88
+ environment?: EnvironmentContext
89
+ telemetry_freshness?: {
90
+ received_at: string | null
91
+ aggregated_at?: string | null
92
+ processing_lag_seconds: number | null
93
+ }
94
+ window?: Window
95
+ range?: WindowRange
96
+ step?: Step
97
+ steps?: Step[]
98
+ flash: FlashData
99
+ google_oauth: boolean
100
+ embedded?: boolean
101
+ saved_views?: SavedView[]
102
+ [key: string]: unknown
103
+ }
104
+
105
+ export interface User {
106
+ id: number
107
+ name: string
108
+ email: string
109
+ provider: string | null
110
+ avatar?: string
111
+ verified: boolean
112
+ editor: string
113
+ editor_root: string | null
114
+ created_at: string
115
+ updated_at: string
116
+ [key: string]: unknown
117
+ }
118
+
119
+ export interface Session {
120
+ id: string
121
+ user_agent: string
122
+ ip_address: string
123
+ created_at: string
124
+ recently_authenticated: boolean
125
+ }
126
+
127
+ // Telemetry shapes shared by pages
128
+
129
+ export type Percentile = "p50" | "p95" | "p99"
130
+
131
+ // One chart bucket. Durations are null where nothing ran, so a latency
132
+ // line breaks over a quiet bucket instead of dropping to zero.
133
+ export interface SeriesPoint {
134
+ t: string
135
+ count: number
136
+ errors: number
137
+ client_errors: number
138
+ avg: number | null
139
+ p50: number | null
140
+ p95: number | null
141
+ p99: number | null
142
+ }
143
+
144
+ export interface GroupRow {
145
+ group_hash: string
146
+ name: string
147
+ count: number
148
+ errors: number
149
+ client_errors: number
150
+ avg: number
151
+ p50: number
152
+ p95: number
153
+ p99: number
154
+ max: number
155
+ sparkline: number[]
156
+ }
157
+
158
+ export interface Summary {
159
+ count: number
160
+ errors: number
161
+ client_errors: number
162
+ avg: number
163
+ p50: number
164
+ p95: number
165
+ p99: number
166
+ max: number
167
+ }
168
+
169
+ export interface SummaryWithDelta {
170
+ current: Summary
171
+ previous: Summary
172
+ }
173
+
174
+ // Release health: crash-free rates are null, not 0, when there were no
175
+ // sessions (or no users) to divide by.
176
+ export interface ReleaseHealthSummary {
177
+ sessions: number
178
+ users: number
179
+ crash_free_sessions: number | null
180
+ crash_free_users: number | null
181
+ errored: number | null
182
+ avg_duration_ms: number | null
183
+ }
184
+
185
+ export interface SessionStatusPoint {
186
+ t: string
187
+ sessions: number
188
+ ok: number
189
+ errored: number
190
+ crashed: number
191
+ }
192
+
193
+ export interface DeployMarker {
194
+ deploy: string
195
+ ref: string
196
+ at: string
197
+ }
198
+
199
+ export interface IssueRow {
200
+ id: number
201
+ key: string
202
+ title: string
203
+ kind: "exception" | "performance"
204
+ status: "open" | "resolved" | "ignored" | "merged"
205
+ priority: "low" | "normal" | "high" | "urgent"
206
+ culprit?: string | null
207
+ occurrences: number
208
+ affected_users: number
209
+ first_seen_at?: string
210
+ last_seen_at: string
211
+ assignee?: { id: number; name: string } | null
212
+ fingerprint_source?: string | null
213
+ source?: string | null
214
+ application?: { id: number; name: string }
215
+ environment?: { id: number; name: string }
216
+ }
217
+
218
+ export interface ExecutionRow {
219
+ execution_id: string
220
+ name: string
221
+ kind?: string
222
+ status?: number | null
223
+ outcome?: string | null
224
+ duration: number
225
+ occurred_at: string
226
+ user_ref?: string | null
227
+ tenant?: string | null
228
+ exception_preview?: string | null
229
+ inertia_component?: string | null
230
+ queries?: number
231
+ deploy?: string | null
232
+ }
233
+
234
+ export interface Frame {
235
+ file: string
236
+ line: number
237
+ function: string
238
+ in_app: boolean
239
+ code?: Record<string, string>
240
+ }
241
+
242
+ export interface ExceptionDetail {
243
+ id: number
244
+ class_name: string
245
+ message: string
246
+ handled: boolean
247
+ severity?: string
248
+ source?: string
249
+ file?: string | null
250
+ line?: number | null
251
+ frames: Frame[]
252
+ cause?: { class: string; message: string } | null
253
+ locals?: Record<string, string> | null
254
+ context?: string | null
255
+ occurred_at?: string
256
+ execution_id?: string | null
257
+ execution_source?: string | null
258
+ execution_preview?: string | null
259
+ deploy?: string | null
260
+ group_hash?: string
261
+ }
262
+
263
+ export interface TimelineEntry {
264
+ type: string
265
+ id: number
266
+ offset: number
267
+ duration: number | null
268
+ label: string
269
+ stage: string | null
270
+ sql?: string | null
271
+ source?: string | null
272
+ detail?: Record<string, string | number | boolean | null>
273
+ }
274
+
275
+ // A named filter on one environment page, shared through every env page's
276
+ // props. `url` is built server-side from the page's route helper.
277
+ export interface SavedView {
278
+ id: number
279
+ name: string
280
+ page: string
281
+ query: string | null
282
+ window: string | null
283
+ params: Record<string, string>
284
+ pinned: boolean
285
+ shared: boolean
286
+ mine: boolean
287
+ url: string
288
+ }
@@ -0,0 +1,9 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ interface ImportMetaEnv {
4
+ readonly VITE_APP_NAME: string
5
+ }
6
+
7
+ interface ImportMeta {
8
+ readonly env: ImportMetaEnv
9
+ }
@@ -74,11 +74,12 @@ module Railwatch
74
74
  resource = resource.to_sym
75
75
  parsed = parse(query)
76
76
  fields = parsed[:fields].except(*Array(except).map(&:to_s))
77
- scope = scope.where(occurred_at: bounded_time_range(fields, from, to))
77
+ range = bounded_time_range(fields, from, to)
78
+ scope = scope.where(occurred_at: range)
78
79
  scope = apply_common(scope, fields)
79
80
 
80
81
  case resource
81
- when :jobs then apply_jobs(scope, fields, parsed[:text])
82
+ when :jobs then apply_jobs(scope, fields, parsed[:text], range)
82
83
  when :exceptions then apply_exceptions(scope, fields, parsed[:text])
83
84
  when :logs then apply_logs(scope, fields, parsed[:text])
84
85
  when :queries then apply_queries(scope, fields, parsed[:text])
@@ -120,15 +121,14 @@ module Railwatch
120
121
  end
121
122
  private_class_method :apply_common
122
123
 
123
- def self.apply_jobs(scope, fields, text)
124
+ def self.apply_jobs(scope, fields, text, range)
124
125
  outcome = fields["outcome"].presence || fields["status"].presence
125
126
  scope = scope.where(queue: fields["queue"]) if fields["queue"].present?
126
127
  scope = scope.where(outcome: outcome) if outcome
127
128
  scope = scope.where(name: fields["class"]) if fields["class"].present?
128
129
  scope = scope.where(job_id: fields["job_id"]) if fields["job_id"].present?
129
130
  return scope unless text.present?
130
- pattern = "%#{Telemetry::Execution.sanitize_sql_like(text)}%"
131
- scope.where("name LIKE :pattern OR exception_preview LIKE :pattern", pattern: pattern)
131
+ scope.merge(Telemetry::Execution.named_like("job_attempt", text, range, previews: true))
132
132
  end
133
133
  private_class_method :apply_jobs
134
134
 
@@ -17,6 +17,53 @@ module Railwatch
17
17
  scope :between, ->(from, to) { where(occurred_at: from..to) }
18
18
  scope :failed, -> { where("status >= 500 OR outcome = 'failed'") }
19
19
 
20
+ # Executions of `kind` in [from, to] whose name contains `text`, or
21
+ # (with previews: true) whose exception preview does. A LIKE on the rows
22
+ # themselves reads every execution in the window until it has a page:
23
+ # 32 s for a week of the platform's own requests when the text is rare.
24
+ # Names come from a few hundred groups, so the text is matched against
25
+ # the window's rollups and the rows are fetched by group_hash. Rollups
26
+ # trail ingest by up to a minute, so the last FRESH_NAMES are matched on
27
+ # the rows themselves. Only failures carry a preview, and a partial
28
+ # index holds just those rows.
29
+ #
30
+ # Each branch names its index and the outer lookup is by rowid, so the
31
+ # plan does not depend on planner statistics (an embedded install's are
32
+ # sampled, and sampled statistics make kind look selective). When the
33
+ # text is common the old walk is the fast one -- a page of matches
34
+ # turns up in the first few thousand rows -- so a text the rollups say
35
+ # matches DENSE_MATCHES rows or more keeps it. On a copy of the
36
+ # platform's own tenant, a week of job attempts: a text matching
37
+ # nothing took 657 ms walking and 27 ms here; one matching 32,766 took
38
+ # 11 ms walking and 215 ms here.
39
+ FRESH_NAMES = 5.minutes
40
+ DENSE_MATCHES = 1_000
41
+
42
+ # `range` is used as given, so an exclusive or empty one (FilterQuery's
43
+ # after:/before: can narrow a window to nothing) stays that way.
44
+ def self.named_like(kind, text, range, previews: false)
45
+ from, to = range.begin, range.end
46
+ pattern = "%#{sanitize_sql_like(text)}%"
47
+ window = where(kind: kind, occurred_at: range)
48
+ groups = Rollup.for_type(kind).between(from, to).where("name LIKE ? ESCAPE '\\'", pattern)
49
+ if !TelemetryRecord.sqlite? || groups.sum(:count) >= DENSE_MATCHES
50
+ like = previews ? "name LIKE :pattern ESCAPE '\\' OR exception_preview LIKE :pattern ESCAPE '\\'" : "name LIKE :pattern ESCAPE '\\'"
51
+ return window.where(like, pattern: pattern)
52
+ end
53
+
54
+ by_group = indexed_by("index_executions_on_group_hash_and_occurred_at")
55
+ .where(group_hash: groups.distinct.select(:group_hash), occurred_at: range, kind: kind)
56
+ fresh = where(kind: kind, occurred_at: [ from, to - FRESH_NAMES ].max..to).where("name LIKE ? ESCAPE '\\'", pattern)
57
+ ids = [ by_group, fresh ]
58
+ ids << indexed_by("idx_executions_with_preview").where(kind: kind, occurred_at: range)
59
+ .where.not(exception_preview: nil).where("exception_preview LIKE ? ESCAPE '\\'", pattern) if previews
60
+ from("#{quoted_table_name} NOT INDEXED").where(kind: kind, occurred_at: range)
61
+ .where(ids.map { |branch| "#{quoted_table_name}.id IN (#{branch.select(:id).to_sql})" }.join(" OR "))
62
+ end
63
+
64
+ def self.indexed_by(index) = unscoped.from("#{quoted_table_name} INDEXED BY #{index}")
65
+ private_class_method :indexed_by
66
+
20
67
  # Servers that reported an execution since `time`. Asked once per kind:
21
68
  # there is no index led by occurred_at alone, so the obvious
22
69
  # `where(occurred_at: time..).distinct.pluck(:server)` scans the whole
@@ -55,21 +55,16 @@ module Railwatch
55
55
  def self.summarize(relation)
56
56
  rows = relation.to_a
57
57
  return { count: 0, errors: 0, client_errors: 0, avg: 0, p50: 0, p95: 0, p99: 0, max: 0, extra: {} } if rows.empty?
58
- # merge! pushes the row's centroids into one accumulating digest.
59
- # `+` built a brand-new digest from both operands' centroids on every
60
- # row, so merging N rows re-pushed every earlier centroid N times:
61
- # 311 rows took 1.4s where merge! takes 0.5s, for the same percentiles.
62
- merged = TDigest::TDigest.new(0.01)
63
- rows.each { |r| merged.merge!(TDigest::TDigest.from_bytes(r.digest)) if r.digest }
58
+ p50, p95, p99 = merged_percentiles(rows, [ 0.5, 0.95, 0.99 ])
64
59
  count = rows.sum(&:count)
65
60
  {
66
61
  count: count,
67
62
  errors: rows.sum(&:error_count),
68
63
  client_errors: rows.sum(&:client_error_count),
69
64
  avg: count.zero? ? 0 : (rows.sum(&:duration_sum) / count),
70
- p50: merged.percentile(0.5).to_i,
71
- p95: merged.percentile(0.95).to_i,
72
- p99: merged.percentile(0.99).to_i,
65
+ p50: p50,
66
+ p95: p95,
67
+ p99: p99,
73
68
  max: rows.map(&:duration_max).max,
74
69
  # Everything a type puts in `extra` -- llm_call's cost_nanos and
75
70
  # token counts, cache_event's hits and misses -- merged the way
@@ -80,6 +75,75 @@ module Railwatch
80
75
  }
81
76
  end
82
77
 
78
+ # Percentiles of the union of every row's centroids, read straight out
79
+ # of the stored bytes. Merging through TDigest pushed each centroid into
80
+ # a red-black tree one at a time: 2.8 s for a week of the platform's
81
+ # own query rollups (18,674 rows) and 7 s for thirty days, on every
82
+ # cold page load. Sorting the centroids once and walking their
83
+ # cumulative counts answers the same question without re-clustering:
84
+ # 353 ms and 1 s for those two windows. The answer is the first
85
+ # centroid whose running total reaches n * p, which is nearest rank --
86
+ # the rule raw_series uses for sub-hour buckets, so both readings of a
87
+ # window agree. (TDigest#percentile compares each centroid's midpoint
88
+ # instead, which puts the median of 1,000 sevens and 300 nines at 9.)
89
+ def self.merged_percentiles(rows, ps)
90
+ means = []
91
+ counts = []
92
+ rows.each { |row| centroids(row.digest, means, counts) if row.digest }
93
+ return ps.map { 0 } if means.empty?
94
+
95
+ order = (0...means.size).sort_by { |i| means[i] }
96
+ total = counts.sum
97
+ targets = ps.map { |p| total * p }
98
+ found = []
99
+ seen = 0
100
+ order.each do |i|
101
+ seen += counts[i]
102
+ found << means[i] while found.size < targets.size && seen >= targets[found.size]
103
+ break if found.size == targets.size
104
+ end
105
+ found << means[order.last] while found.size < targets.size
106
+ found.map(&:to_i)
107
+ end
108
+
109
+ # Appends one stored digest's centroid means and weights, in either of
110
+ # the tdigest gem's encodings (TDigest.from_bytes reads the same two).
111
+ def self.centroids(bytes, means, counts)
112
+ format, _compression, size = bytes.unpack("LdL")
113
+ case format
114
+ when TDigest::TDigest::VERBOSE_ENCODING
115
+ means.concat(bytes.unpack("@16d#{size}"))
116
+ counts.concat(bytes.unpack("@#{16 + 8 * size}L#{size}"))
117
+ when TDigest::TDigest::SMALL_ENCODING
118
+ # Means are delta-encoded 4-byte floats; weights are 7-bit varints,
119
+ # one byte each unless a centroid holds 128 or more samples.
120
+ mean = 0.0
121
+ bytes.unpack("@16f#{size}").each { |delta| means << (mean += delta) }
122
+ weights = bytes.byteslice(16 + 4 * size, bytes.bytesize).unpack("C*")
123
+ if weights.size == size
124
+ counts.concat(weights)
125
+ else
126
+ at = 0
127
+ size.times do
128
+ byte = weights[at]
129
+ at += 1
130
+ weight = byte & 0x7f
131
+ shift = 7
132
+ while byte & 0x80 != 0
133
+ byte = weights[at] || 0
134
+ at += 1
135
+ weight += (byte & 0x7f) << shift
136
+ shift += 7
137
+ end
138
+ counts << weight
139
+ end
140
+ end
141
+ else
142
+ raise ArgumentError, "unknown t-digest encoding #{format}"
143
+ end
144
+ end
145
+ private_class_method :centroids
146
+
83
147
  def self.merge_extras(rows)
84
148
  rows.each_with_object({}) do |row, out|
85
149
  row.extra.each do |key, value|
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Two dashboard aggregates read every row in the window and only a few
4
+ # narrow columns of each: the Jobs page's per-queue breakdown and the
5
+ # Processes page's health series. Through (kind, occurred_at) and
6
+ # (sampled_at) SQLite finds the rows in order but then fetches each one
7
+ # from the table, and on a telemetry file much larger than memory those
8
+ # rows are scattered pages on disk. On the platform's own 30 GB tenant,
9
+ # over a week: 38 s for the queue breakdown (491,000 job attempts) and
10
+ # 19 s for the health series (263,000 samples), with the GVL held the
11
+ # whole time, so every other request in the process waited too.
12
+ #
13
+ # Both indexes carry every column their query reads, so the whole answer
14
+ # comes from one contiguous index range: 335 ms and 370 ms on a copy of
15
+ # the same file. They cost 151 MB and 29 MB there, and took 13 s and 2 s
16
+ # to build.
17
+ #
18
+ # broadcasts is the one windowed table with no index led by occurred_at,
19
+ # so the Broadcasts page's "recent" list sorted the whole window to show
20
+ # 100 rows. Every other raw table has had this index since it was created.
21
+ #
22
+ # Free-text search over jobs and requests also matches the exception
23
+ # preview, which only a failed execution carries (15 of 491,000 job
24
+ # attempts in that same week). A partial index over just those rows lets
25
+ # Execution.named_like seek them instead of reading the window.
26
+ class AddCoveringIndexesForDashboardAggregates < ActiveRecord::Migration[8.1]
27
+ def change
28
+ add_index :executions, [ :kind, :occurred_at, :queue, :outcome, :queue_latency ], name: "idx_executions_queue_stats"
29
+ add_index :health_samples, [ :sampled_at, :threads_max, :threads_busy, :backlog, :queue_depth, :queue_latency ],
30
+ name: "idx_health_samples_series"
31
+ add_index :broadcasts, :occurred_at
32
+ add_index :executions, [ :kind, :occurred_at ], name: "idx_executions_with_preview", where: "exception_preview IS NOT NULL"
33
+ end
34
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Railwatch
4
- VERSION = "0.6.1"
4
+ VERSION = "0.8.0"
5
5
  end
data/lib/railwatch.rb CHANGED
@@ -634,15 +634,20 @@ module Railwatch
634
634
  end
635
635
 
636
636
  # The one browser client, relative to the gem root. The dashboard bundle
637
- # is built from it, the install generator copies it, the gem ships it (the
638
- # only app/frontend file it does), and a host with its own frontend build
639
- # (Railwatch Cloud) imports it from browser_client_path.
637
+ # is built from it and the install generator copies it into a host.
640
638
  BROWSER_CLIENT = "app/frontend/lib/railwatch.ts"
641
639
 
642
640
  def self.browser_client_path
643
641
  File.expand_path("../#{BROWSER_CLIENT}", __dir__)
644
642
  end
645
643
 
644
+ # The dashboard's frontend source. Railwatch Cloud builds its bundle from
645
+ # this directory plus its own hosting pages, so there is one copy of every
646
+ # dashboard page and component.
647
+ def self.frontend_path
648
+ File.expand_path("../app/frontend", __dir__)
649
+ end
650
+
646
651
  # One realistic wire record per record type, exactly as the gem's own
647
652
  # subscribers emit it, generated by spec/railwatch/wire_fixtures_spec.rb.
648
653
  # A receiver tests its mapper against these instead of hand-writing what