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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +13 -0
- data/app/frontend/components/app-content.tsx +26 -0
- data/app/frontend/components/app-logo-icon.tsx +23 -0
- data/app/frontend/components/app-logo.tsx +14 -0
- data/app/frontend/components/app-shell.tsx +38 -0
- data/app/frontend/components/app-sidebar-header.tsx +54 -0
- data/app/frontend/components/app-sidebar.tsx +94 -0
- data/app/frontend/components/app-wordmark.tsx +29 -0
- data/app/frontend/components/breadcrumbs.tsx +46 -0
- data/app/frontend/components/icon.tsx +16 -0
- data/app/frontend/components/input-error.tsx +18 -0
- data/app/frontend/components/nav-footer.tsx +46 -0
- data/app/frontend/components/nav-main.tsx +41 -0
- data/app/frontend/components/nav-user.tsx +50 -0
- data/app/frontend/components/railwatch/chart-hover.tsx +34 -0
- data/app/frontend/components/railwatch/chart-panel.tsx +170 -0
- data/app/frontend/components/railwatch/code.tsx +28 -0
- data/app/frontend/components/railwatch/command-palette.tsx +132 -0
- data/app/frontend/components/railwatch/copy-block.tsx +52 -0
- data/app/frontend/components/railwatch/copy-id.tsx +49 -0
- data/app/frontend/components/railwatch/cursor-load-more.tsx +39 -0
- data/app/frontend/components/railwatch/data-table.tsx +195 -0
- data/app/frontend/components/railwatch/digit-roll.tsx +312 -0
- data/app/frontend/components/railwatch/empty-state.tsx +159 -0
- data/app/frontend/components/railwatch/env-nav.tsx +130 -0
- data/app/frontend/components/railwatch/env-switcher.tsx +145 -0
- data/app/frontend/components/railwatch/filter-bar.tsx +153 -0
- data/app/frontend/components/railwatch/flamegraph.tsx +324 -0
- data/app/frontend/components/railwatch/frames.tsx +295 -0
- data/app/frontend/components/railwatch/json-viewer.tsx +159 -0
- data/app/frontend/components/railwatch/live-dot.tsx +78 -0
- data/app/frontend/components/railwatch/live-toggle.tsx +76 -0
- data/app/frontend/components/railwatch/metric-chart.tsx +112 -0
- data/app/frontend/components/railwatch/mobile-page-switcher.tsx +89 -0
- data/app/frontend/components/railwatch/onboarding.tsx +91 -0
- data/app/frontend/components/railwatch/origin-identity.tsx +84 -0
- data/app/frontend/components/railwatch/page-header.tsx +42 -0
- data/app/frontend/components/railwatch/percentile-picker.tsx +18 -0
- data/app/frontend/components/railwatch/relative-time.tsx +18 -0
- data/app/frontend/components/railwatch/saved-views.tsx +232 -0
- data/app/frontend/components/railwatch/segmented.tsx +64 -0
- data/app/frontend/components/railwatch/series-chart.tsx +400 -0
- data/app/frontend/components/railwatch/shortcuts-dialog.tsx +89 -0
- data/app/frontend/components/railwatch/signal-toast.tsx +121 -0
- data/app/frontend/components/railwatch/sort-header.tsx +37 -0
- data/app/frontend/components/railwatch/source-link.tsx +57 -0
- data/app/frontend/components/railwatch/sparkline-cell.tsx +14 -0
- data/app/frontend/components/railwatch/sql-block.tsx +116 -0
- data/app/frontend/components/railwatch/stat.tsx +173 -0
- data/app/frontend/components/railwatch/status-badge.tsx +79 -0
- data/app/frontend/components/railwatch/step-picker.tsx +57 -0
- data/app/frontend/components/railwatch/timeline.tsx +405 -0
- data/app/frontend/components/railwatch/utilisation-bar.tsx +44 -0
- data/app/frontend/components/railwatch/window-picker.tsx +147 -0
- data/app/frontend/components/ui/avatar.tsx +51 -0
- data/app/frontend/components/ui/badge.tsx +46 -0
- data/app/frontend/components/ui/breadcrumb.tsx +109 -0
- data/app/frontend/components/ui/button.tsx +60 -0
- data/app/frontend/components/ui/card.tsx +92 -0
- data/app/frontend/components/ui/chart.tsx +374 -0
- data/app/frontend/components/ui/checkbox.tsx +32 -0
- data/app/frontend/components/ui/collapsible.tsx +31 -0
- data/app/frontend/components/ui/command.tsx +182 -0
- data/app/frontend/components/ui/dialog.tsx +158 -0
- data/app/frontend/components/ui/dropdown-menu.tsx +255 -0
- data/app/frontend/components/ui/hover-card.tsx +42 -0
- data/app/frontend/components/ui/input.tsx +21 -0
- data/app/frontend/components/ui/label.tsx +24 -0
- data/app/frontend/components/ui/popover.tsx +87 -0
- data/app/frontend/components/ui/select.tsx +185 -0
- data/app/frontend/components/ui/separator.tsx +28 -0
- data/app/frontend/components/ui/sheet.tsx +137 -0
- data/app/frontend/components/ui/sidebar.tsx +726 -0
- data/app/frontend/components/ui/skeleton.tsx +13 -0
- data/app/frontend/components/ui/sonner.tsx +41 -0
- data/app/frontend/components/ui/table.tsx +114 -0
- data/app/frontend/components/ui/tabs.tsx +91 -0
- data/app/frontend/components/ui/textarea.tsx +18 -0
- data/app/frontend/components/ui/tooltip.tsx +61 -0
- data/app/frontend/components/user-info.tsx +32 -0
- data/app/frontend/components/user-menu-content.tsx +94 -0
- data/app/frontend/entrypoints/application.css +244 -0
- data/app/frontend/entrypoints/inertia.tsx +95 -0
- data/app/frontend/fonts/CommitMono-Bold.woff2 +0 -0
- data/app/frontend/fonts/CommitMono-Regular.woff2 +0 -0
- data/app/frontend/fonts/Roboto-Black.woff2 +0 -0
- data/app/frontend/fonts/Roboto-Bold.woff2 +0 -0
- data/app/frontend/fonts/Roboto-Medium.woff2 +0 -0
- data/app/frontend/fonts/Roboto-Regular.woff2 +0 -0
- data/app/frontend/hooks/use-appearance.tsx +67 -0
- data/app/frontend/hooks/use-clipboard.ts +32 -0
- data/app/frontend/hooks/use-flash.tsx +23 -0
- data/app/frontend/hooks/use-hotkeys.ts +63 -0
- data/app/frontend/hooks/use-initials.tsx +15 -0
- data/app/frontend/hooks/use-live.ts +179 -0
- data/app/frontend/hooks/use-mobile-navigation.ts +8 -0
- data/app/frontend/hooks/use-mobile.ts +21 -0
- data/app/frontend/hooks/use-percentile.ts +24 -0
- data/app/frontend/hooks/use-reduced-motion.ts +25 -0
- data/app/frontend/hooks/use-row-nav.ts +106 -0
- data/app/frontend/hooks/use-step.ts +27 -0
- data/app/frontend/hooks/use-ticker.ts +12 -0
- data/app/frontend/hooks/use-window.ts +58 -0
- data/app/frontend/layouts/app/app-sidebar-layout.tsx +107 -0
- data/app/frontend/layouts/app-layout.tsx +21 -0
- data/app/frontend/layouts/env-layout.tsx +53 -0
- data/app/frontend/layouts/persistent-layout.tsx +18 -0
- data/app/frontend/lib/cable.ts +10 -0
- data/app/frontend/lib/env-pages.ts +236 -0
- data/app/frontend/lib/execution-path.ts +55 -0
- data/app/frontend/lib/explain.ts +50 -0
- data/app/frontend/lib/filter.ts +25 -0
- data/app/frontend/lib/flamegraph.ts +155 -0
- data/app/frontend/lib/format.ts +71 -0
- data/app/frontend/lib/keyboard.ts +18 -0
- data/app/frontend/lib/source-link.ts +217 -0
- data/app/frontend/lib/utils.ts +6 -0
- data/app/frontend/pages/alerts/index.tsx +171 -0
- data/app/frontend/pages/broadcasts/index.tsx +171 -0
- data/app/frontend/pages/cache_events/index.tsx +127 -0
- data/app/frontend/pages/commands/index.tsx +143 -0
- data/app/frontend/pages/deploys/index.tsx +94 -0
- data/app/frontend/pages/deploys/show.tsx +309 -0
- data/app/frontend/pages/deprecations/index.tsx +176 -0
- data/app/frontend/pages/exceptions/index.tsx +271 -0
- data/app/frontend/pages/executions/show.tsx +821 -0
- data/app/frontend/pages/issues/index.tsx +399 -0
- data/app/frontend/pages/issues/show.tsx +1367 -0
- data/app/frontend/pages/jobs/index.tsx +381 -0
- data/app/frontend/pages/jobs/klass.tsx +215 -0
- data/app/frontend/pages/llm_calls/index.tsx +664 -0
- data/app/frontend/pages/logs/index.tsx +303 -0
- data/app/frontend/pages/mails/index.tsx +218 -0
- data/app/frontend/pages/notifications/index.tsx +161 -0
- data/app/frontend/pages/outgoing_requests/index.tsx +309 -0
- data/app/frontend/pages/overview/show.tsx +333 -0
- data/app/frontend/pages/people/index.tsx +102 -0
- data/app/frontend/pages/people/show.tsx +149 -0
- data/app/frontend/pages/processes/index.tsx +335 -0
- data/app/frontend/pages/profiles/index.tsx +110 -0
- data/app/frontend/pages/profiles/show.tsx +105 -0
- data/app/frontend/pages/queries/index.tsx +492 -0
- data/app/frontend/pages/queries/show.tsx +276 -0
- data/app/frontend/pages/releases/index.tsx +190 -0
- data/app/frontend/pages/releases/release-health.tsx +68 -0
- data/app/frontend/pages/releases/release-path.ts +25 -0
- data/app/frontend/pages/releases/show.tsx +262 -0
- data/app/frontend/pages/requests/index.tsx +235 -0
- data/app/frontend/pages/requests/route.tsx +159 -0
- data/app/frontend/pages/scheduled_tasks/index.tsx +220 -0
- data/app/frontend/pages/spans/index.tsx +229 -0
- data/app/frontend/pages/spans/show.tsx +220 -0
- data/app/frontend/pages/storage_ops/index.tsx +150 -0
- data/app/frontend/pages/tenants/index.tsx +236 -0
- data/app/frontend/pages/tenants/show.tsx +477 -0
- data/app/frontend/pages/tenants/tenant-path.ts +26 -0
- data/app/frontend/pages/thresholds/index.tsx +414 -0
- data/app/frontend/pages/traces/show.tsx +208 -0
- data/app/frontend/pages/transactions/index.tsx +220 -0
- data/app/frontend/pages/view_renders/index.tsx +241 -0
- data/app/frontend/pages/visits/index.tsx +321 -0
- data/app/frontend/public/icon.png +0 -0
- data/app/frontend/public/icon.svg +5 -0
- data/app/frontend/routes/index.d.ts +2136 -0
- data/app/frontend/routes/index.js +2112 -0
- data/app/frontend/types/globals.d.ts +9 -0
- data/app/frontend/types/index.ts +288 -0
- data/app/frontend/types/vite-env.d.ts +9 -0
- data/lib/railwatch/version.rb +1 -1
- data/lib/railwatch.rb +8 -3
- data/public/railwatch/assets/app-layout-LvxN9xSS.js +41 -0
- data/public/railwatch/assets/application-Cc5v_Vk9.css +1 -0
- data/public/railwatch/assets/{arrow-up-CLQ-7heQ.js → arrow-up-DG01k9AL.js} +1 -1
- data/public/railwatch/assets/{braces-rSYydpLY.js → braces-C3w7pfxS.js} +1 -1
- data/public/railwatch/assets/{card-DPjFKfen.js → card-Dpwlr0uY.js} +1 -1
- data/public/railwatch/assets/{chart-DWh7l8yM.js → chart-DJHv3R7W.js} +1 -1
- data/public/railwatch/assets/{chart-hover-CfoZUY4J.js → chart-hover-CEH-DtXO.js} +1 -1
- data/public/railwatch/assets/{chart-panel-CC49WTQL.js → chart-panel-CUdIzJvk.js} +1 -1
- data/public/railwatch/assets/code-BRZprwun.js +1 -0
- data/public/railwatch/assets/copy-id-CwhGGiwF.js +1 -0
- data/public/railwatch/assets/cursor-load-more-uF1_955Q.js +1 -0
- data/public/railwatch/assets/data-table-DYi-N0hm.js +1 -0
- data/public/railwatch/assets/env-layout-HdcOaPPX.js +1 -0
- data/public/railwatch/assets/{execution-path-FAuIzOXB.js → execution-path-CElXXFO_.js} +1 -1
- data/public/railwatch/assets/filter-bar-DEv0G_34.js +1 -0
- data/public/railwatch/assets/{flamegraph-EaGkP2NT.js → flamegraph-SKD9yxW9.js} +2 -2
- data/public/railwatch/assets/frames-CUpyJQDy.js +1 -0
- data/public/railwatch/assets/index-2W-ppwap.js +1 -0
- data/public/railwatch/assets/index-74ocuU5d.js +1 -0
- data/public/railwatch/assets/{index-C8Cggnbw.js → index-B8NA62Lz.js} +2 -2
- data/public/railwatch/assets/index-BBCEkLqp.js +1 -0
- data/public/railwatch/assets/index-BDcglJzX.js +1 -0
- data/public/railwatch/assets/index-B_KFb9LS.js +1 -0
- data/public/railwatch/assets/index-BqK_VKom.js +1 -0
- data/public/railwatch/assets/index-BxCXBHP2.js +1 -0
- data/public/railwatch/assets/index-C1jc1OoH.js +1 -0
- data/public/railwatch/assets/index-C7zREldp.js +1 -0
- data/public/railwatch/assets/index-CEetm4oC.js +1 -0
- data/public/railwatch/assets/index-CIaZkokF.js +1 -0
- data/public/railwatch/assets/index-CNH85TZp.js +1 -0
- data/public/railwatch/assets/index-Ce4SB5AB.js +1 -0
- data/public/railwatch/assets/index-CiRMSYMn.js +1 -0
- data/public/railwatch/assets/index-CjxXgOs-.js +1 -0
- data/public/railwatch/assets/index-ClL-PYEV.js +1 -0
- data/public/railwatch/assets/index-CteZ6fop.js +1 -0
- data/public/railwatch/assets/index-CvqfEExO.js +1 -0
- data/public/railwatch/assets/index-CxTLT-Qh.js +1 -0
- data/public/railwatch/assets/index-DJd3OUSd.js +1 -0
- data/public/railwatch/assets/index-DPD-rLp6.js +1 -0
- data/public/railwatch/assets/index-DYK_dps4.js +1 -0
- data/public/railwatch/assets/index-DrayARrI.js +1 -0
- data/public/railwatch/assets/index-HRXGir-0.js +1 -0
- data/public/railwatch/assets/index-UJOUALr_.js +1 -0
- data/public/railwatch/assets/index-d-3J7gQ5.js +1 -0
- data/public/railwatch/assets/index-oQrvBpOm.js +1 -0
- data/public/railwatch/assets/inertia-FoqYZAk8.js +97 -0
- data/public/railwatch/assets/json-viewer-CxqMojEm.js +1 -0
- data/public/railwatch/assets/klass-DEF-Vat5.js +1 -0
- data/public/railwatch/assets/origin-identity-Y6gvfQVR.js +1 -0
- data/public/railwatch/assets/{percentile-picker-CeQgllxD.js → percentile-picker-E2aUdLxx.js} +1 -1
- data/public/railwatch/assets/relative-time-DcJbkKlN.js +1 -0
- data/public/railwatch/assets/{release-health-uP-GF3_V.js → release-health-C8WSCDKD.js} +1 -1
- data/public/railwatch/assets/route-qO5vX1p-.js +1 -0
- data/public/railwatch/assets/{segmented-BeIe4uqk.js → segmented-BWee8uc9.js} +1 -1
- data/public/railwatch/assets/select-G7rNBzv0.js +1 -0
- data/public/railwatch/assets/series-chart-C5nXIUc5.js +1 -0
- data/public/railwatch/assets/show-BEWisLQw.js +1 -0
- data/public/railwatch/assets/show-BEa9PGu1.js +2 -0
- data/public/railwatch/assets/{show-YsNpqbcI.js → show-BiT_Aloc.js} +3 -3
- data/public/railwatch/assets/show-BpCO49d3.js +1 -0
- data/public/railwatch/assets/show-CH7yxt7R.js +1 -0
- data/public/railwatch/assets/show-CTO8Lvd2.js +1 -0
- data/public/railwatch/assets/show-DnYsDMUt.js +2 -0
- data/public/railwatch/assets/show-DyzHM527.js +1 -0
- data/public/railwatch/assets/show-Nfpxs8su.js +1 -0
- data/public/railwatch/assets/show-ddjUMo9G.js +1 -0
- data/public/railwatch/assets/show-ksjbSzDH.js +1 -0
- data/public/railwatch/assets/{sort-header-mEJUM3Av.js → sort-header-uOq5VlrB.js} +1 -1
- data/public/railwatch/assets/{sparkline-cell-Dwg7awwh.js → sparkline-cell-DgGij0cu.js} +1 -1
- data/public/railwatch/assets/stat-BTzNKTm2.js +1 -0
- data/public/railwatch/assets/status-badge-BDkOXa-C.js +1 -0
- data/public/railwatch/assets/{tenant-path-CQoP-BeF.js → tenant-path-DU0ezKSU.js} +1 -1
- data/public/railwatch/assets/{use-clipboard-DNefo-ky.js → use-clipboard-DoENmlWY.js} +1 -1
- data/public/railwatch/assets/{use-live-DMTuhKfB.js → use-live-CE_fN5_6.js} +1 -1
- data/public/railwatch/manifest.json +612 -1591
- metadata +242 -117
- data/public/railwatch/assets/app-layout-Zc0v-hYh.js +0 -1
- data/public/railwatch/assets/app-wordmark-BA_60AVb.js +0 -1
- data/public/railwatch/assets/appearance-CcfP9tZ7.js +0 -1
- data/public/railwatch/assets/application-BrN3Sz94.css +0 -1
- data/public/railwatch/assets/auth-layout-C05gEBIQ.js +0 -1
- data/public/railwatch/assets/badge-DRae8XwK.js +0 -1
- data/public/railwatch/assets/checkbox-DPkLUiwM.js +0 -1
- data/public/railwatch/assets/code-CLmYS6FU.js +0 -1
- data/public/railwatch/assets/copy-DaQMJWoT.js +0 -1
- data/public/railwatch/assets/copy-block-CGcXxp8J.js +0 -1
- data/public/railwatch/assets/copy-id-vBYHQwxg.js +0 -1
- data/public/railwatch/assets/cursor-load-more-Ddwxe5dZ.js +0 -1
- data/public/railwatch/assets/data-table-CzKTEE-O.js +0 -1
- data/public/railwatch/assets/edit-B1kmWkzd.js +0 -8
- data/public/railwatch/assets/edit-D9cx4pbG.js +0 -1
- data/public/railwatch/assets/edit-yv6j9p-V.js +0 -1
- data/public/railwatch/assets/empty-state-CW4wclK_.js +0 -1
- data/public/railwatch/assets/env-layout-Kz7wks1x.js +0 -1
- data/public/railwatch/assets/filter-bar-CJDjWFib.js +0 -1
- data/public/railwatch/assets/frames-zZuIaNH9.js +0 -1
- data/public/railwatch/assets/google-sign-in-button-2_zgbgVy.js +0 -1
- data/public/railwatch/assets/index-B49SWz7K.js +0 -1
- data/public/railwatch/assets/index-BHlY4wKe.js +0 -1
- data/public/railwatch/assets/index-BZpPtyFY.js +0 -1
- data/public/railwatch/assets/index-BeVK6jCL.js +0 -1
- data/public/railwatch/assets/index-BfbSo01U.js +0 -1
- data/public/railwatch/assets/index-BfgncAv6.js +0 -1
- data/public/railwatch/assets/index-BhNszK1k.js +0 -1
- data/public/railwatch/assets/index-Bu01uWvw.js +0 -1
- data/public/railwatch/assets/index-C1s_hK3p.js +0 -1
- data/public/railwatch/assets/index-CBip6V4z.js +0 -1
- data/public/railwatch/assets/index-CMJGss5R.js +0 -1
- data/public/railwatch/assets/index-CRo3yK20.js +0 -1
- data/public/railwatch/assets/index-CWB_p2J8.js +0 -1
- data/public/railwatch/assets/index-CWHpndGc.js +0 -1
- data/public/railwatch/assets/index-Ca_S4Sc3.js +0 -1
- data/public/railwatch/assets/index-CanPDDOa.js +0 -1
- data/public/railwatch/assets/index-Cie90Yat.js +0 -1
- data/public/railwatch/assets/index-CiepQ_pR.js +0 -1
- data/public/railwatch/assets/index-Cm1uCIGN.js +0 -1
- data/public/railwatch/assets/index-CzitnnSC.js +0 -1
- data/public/railwatch/assets/index-DEUfClv3.js +0 -1
- data/public/railwatch/assets/index-DJKwo-mI.js +0 -1
- data/public/railwatch/assets/index-DK6y0YHp.js +0 -1
- data/public/railwatch/assets/index-DMNPpLH9.js +0 -1
- data/public/railwatch/assets/index-DZO1mSfX.js +0 -1
- data/public/railwatch/assets/index-Db5wj3M5.js +0 -1
- data/public/railwatch/assets/index-Dcy5WktB.js +0 -1
- data/public/railwatch/assets/index-DvG0-7Lx.js +0 -1
- data/public/railwatch/assets/index-Dvj1wuka.js +0 -1
- data/public/railwatch/assets/index-KyZX46qX.js +0 -1
- data/public/railwatch/assets/index-Ze-KP-sl.js +0 -1
- data/public/railwatch/assets/index-mhRbWLBM.js +0 -1
- data/public/railwatch/assets/index-x099JL5f.js +0 -1
- data/public/railwatch/assets/index-x28zb_nf.js +0 -1
- data/public/railwatch/assets/inertia-Cuyz2ZHO.js +0 -97
- data/public/railwatch/assets/input-error-hog6gGxg.js +0 -1
- data/public/railwatch/assets/json-viewer-DH2W9HXf.js +0 -1
- data/public/railwatch/assets/klass-DHbDelLk.js +0 -1
- data/public/railwatch/assets/label-DdCBgiUn.js +0 -1
- data/public/railwatch/assets/layout-Cueyl7c5.js +0 -1
- data/public/railwatch/assets/live-dot-BZgYTYdt.js +0 -41
- data/public/railwatch/assets/nav-BSSGObDZ.js +0 -1
- data/public/railwatch/assets/new-B8FSb8Bl.js +0 -1
- data/public/railwatch/assets/new-C39_v2Ll.js +0 -1
- data/public/railwatch/assets/new-DVOPwaC5.js +0 -1
- data/public/railwatch/assets/new-Dd40nvJR.js +0 -1
- data/public/railwatch/assets/new-SxnYe1SE.js +0 -1
- data/public/railwatch/assets/new-eP5vKD3Y.js +0 -1
- data/public/railwatch/assets/onboarding-CUpZl5KB.js +0 -1
- data/public/railwatch/assets/origin-identity-BYt2uiuo.js +0 -1
- data/public/railwatch/assets/relative-time-D5UbF4oO.js +0 -1
- data/public/railwatch/assets/route-DQAY8JEr.js +0 -1
- data/public/railwatch/assets/select-2R16457Z.js +0 -1
- data/public/railwatch/assets/separator-D5I0UCB5.js +0 -1
- data/public/railwatch/assets/series-chart-gnrzhmM6.js +0 -1
- data/public/railwatch/assets/show-2BkeNRUC.js +0 -2
- data/public/railwatch/assets/show-B0X1hRQH.js +0 -1
- data/public/railwatch/assets/show-BKUV5l5q.js +0 -1
- data/public/railwatch/assets/show-BQJD_CsF.js +0 -6
- data/public/railwatch/assets/show-BhmD6Sxp.js +0 -1
- data/public/railwatch/assets/show-C3KcnVo2.js +0 -1
- data/public/railwatch/assets/show-C95cHd06.js +0 -2
- data/public/railwatch/assets/show-CdB4uVQS.js +0 -1
- data/public/railwatch/assets/show-CoCqcVIp.js +0 -1
- data/public/railwatch/assets/show-D2LqjEsU.js +0 -1
- data/public/railwatch/assets/show-DNpnKyTh.js +0 -1
- data/public/railwatch/assets/show-DOlTxbig.js +0 -1
- data/public/railwatch/assets/show-DQCkttL8.js +0 -1
- data/public/railwatch/assets/show-DcxesipC.js +0 -1
- data/public/railwatch/assets/show-IGJoc_X0.js +0 -1
- data/public/railwatch/assets/show-qNV6H8SH.js +0 -2
- data/public/railwatch/assets/stat-ZtxGU8lE.js +0 -1
- data/public/railwatch/assets/status-badge-CH5P-Xkj.js +0 -1
- data/public/railwatch/assets/text-link-DHJ8BXx5.js +0 -1
- data/public/railwatch/assets/textarea-BeQtQyl5.js +0 -1
- data/public/railwatch/assets/timeline-CaKQVu48.js +0 -1
- data/public/railwatch/assets/transition-ksDpqhKJ.js +0 -5
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react"
|
|
2
|
+
|
|
3
|
+
import { cn } from "@/lib/utils"
|
|
4
|
+
|
|
5
|
+
// A number whose digits roll when it changes: the digits that actually
|
|
6
|
+
// changed slide up (or down, when the number falls) to their new glyph and
|
|
7
|
+
// the rest of the number stays exactly where it is.
|
|
8
|
+
//
|
|
9
|
+
// This is motion on an operational dashboard, which is a thing to be
|
|
10
|
+
// careful with rather than a thing to sprinkle around -- see the gates on
|
|
11
|
+
// `Stat`'s rolling path, which is what decides whether this component is
|
|
12
|
+
// rendered at all. What this component owns is the part of the contract
|
|
13
|
+
// that no caller can get right by itself:
|
|
14
|
+
//
|
|
15
|
+
// * the roll is 300ms, so it is over long before the next update can
|
|
16
|
+
// arrive (see ROLL_MIN_INTERVAL_MS) and the strip is never in motion
|
|
17
|
+
// for more than ~15% of the time;
|
|
18
|
+
// * updates are coalesced to one roll every 2s. An update that lands
|
|
19
|
+
// inside that window is remembered, not painted, and the roll that
|
|
20
|
+
// follows goes straight to the newest value. A roll is never
|
|
21
|
+
// interrupted, because an animation that never finishes is worse than
|
|
22
|
+
// no animation at all;
|
|
23
|
+
// * the first paint is static. A page that opens, or one that is
|
|
24
|
+
// navigated back to, shows its numbers rather than animating up to
|
|
25
|
+
// them.
|
|
26
|
+
//
|
|
27
|
+
// The cells are built by hand rather than rendered, because they are keyed
|
|
28
|
+
// by place value and animated with the Web Animations API, and neither of
|
|
29
|
+
// those survives React re-rendering the digits underneath them. They are
|
|
30
|
+
// decoration -- whoever renders this is expected to hide it from assistive
|
|
31
|
+
// tech and put the number in a text node beside it (`Stat` does).
|
|
32
|
+
|
|
33
|
+
// The one switch. Flip this to false and every rolling number in the app --
|
|
34
|
+
// the dashboard's stats, and whatever the landing page does with the same
|
|
35
|
+
// component -- becomes a plain static number.
|
|
36
|
+
export const DIGIT_ROLL_ENABLED = true
|
|
37
|
+
|
|
38
|
+
export const ROLL_DURATION_MS = 300
|
|
39
|
+
export const ROLL_EASING = "cubic-bezier(.22, 1, .36, 1)"
|
|
40
|
+
export const ROLL_MIN_INTERVAL_MS = 2_000
|
|
41
|
+
|
|
42
|
+
// Fixed em heights on every piece, so a roll cannot change the line box and
|
|
43
|
+
// the strip below the number cannot be pushed around by it.
|
|
44
|
+
const CELL_CLASS =
|
|
45
|
+
"inline-block h-[1.15em] overflow-hidden leading-[1.15em] align-top"
|
|
46
|
+
const SEPARATOR_CLASS =
|
|
47
|
+
"inline-block h-[1.15em] overflow-visible leading-[1.15em] align-top text-center"
|
|
48
|
+
const STRIP_CLASS = "block will-change-transform"
|
|
49
|
+
const SLOT_CLASS = "block h-[1.15em] leading-[1.15em]"
|
|
50
|
+
const SUFFIX_CLASS = "inline-block h-[1.15em] leading-[1.15em]"
|
|
51
|
+
|
|
52
|
+
type Direction = -1 | 0 | 1
|
|
53
|
+
|
|
54
|
+
// A thousands separator (or a decimal point, or a minus sign) never rolls,
|
|
55
|
+
// so it is a plain glyph rather than a two-slot stack.
|
|
56
|
+
interface SeparatorCell {
|
|
57
|
+
separator: true
|
|
58
|
+
el: HTMLSpanElement
|
|
59
|
+
char: string | null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface DigitCell {
|
|
63
|
+
separator: false
|
|
64
|
+
el: HTMLSpanElement
|
|
65
|
+
strip: HTMLSpanElement
|
|
66
|
+
top: HTMLSpanElement
|
|
67
|
+
bottom: HTMLSpanElement
|
|
68
|
+
animation: Animation | null
|
|
69
|
+
char: string | null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
type Cell = SeparatorCell | DigitCell
|
|
73
|
+
|
|
74
|
+
function span(className: string) {
|
|
75
|
+
const el = document.createElement("span")
|
|
76
|
+
el.className = className
|
|
77
|
+
return el
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// "412,908ms" -> { digits: "412,908", suffix: "ms" }. The unit is one glyph
|
|
81
|
+
// run at the end of the number that nothing ever moves.
|
|
82
|
+
function split(formatted: string) {
|
|
83
|
+
let end = formatted.length
|
|
84
|
+
while (end > 0 && !/[0-9]/.test(formatted[end - 1])) end--
|
|
85
|
+
return { digits: formatted.slice(0, end), suffix: formatted.slice(end) }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function makeCell(separator: boolean): Cell {
|
|
89
|
+
const el = span(separator ? SEPARATOR_CLASS : CELL_CLASS)
|
|
90
|
+
if (separator) return { separator: true, el, char: null }
|
|
91
|
+
const strip = span(STRIP_CLASS)
|
|
92
|
+
const top = span(SLOT_CLASS)
|
|
93
|
+
const bottom = span(SLOT_CLASS)
|
|
94
|
+
strip.append(top, bottom)
|
|
95
|
+
el.append(strip)
|
|
96
|
+
return {
|
|
97
|
+
separator: false,
|
|
98
|
+
el,
|
|
99
|
+
strip,
|
|
100
|
+
top,
|
|
101
|
+
bottom,
|
|
102
|
+
animation: null,
|
|
103
|
+
char: null,
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Cells are keyed by distance from the right of the number, so a digit keeps
|
|
108
|
+
// its identity -- its place value -- even when the number gains a digit:
|
|
109
|
+
// 999 -> 1,000 leaves keys 0,1,2 rolling 9 -> 0 and simply inserts key 3
|
|
110
|
+
// (the comma) and key 4 (the 1). No re-keying, no cascade, and the digits
|
|
111
|
+
// that did not change never move.
|
|
112
|
+
function createRoll(
|
|
113
|
+
host: HTMLElement,
|
|
114
|
+
{ durationMs, easing }: { durationMs: number; easing: string },
|
|
115
|
+
) {
|
|
116
|
+
const cells = new Map<number, Cell>()
|
|
117
|
+
let order = ""
|
|
118
|
+
|
|
119
|
+
// Collapse a cell back to the single digit that is showing. The glyph it
|
|
120
|
+
// rolled away from is out of view but still in the DOM, and a selection
|
|
121
|
+
// (or a copy) would otherwise pick it up.
|
|
122
|
+
function settle(cell: DigitCell, char: string) {
|
|
123
|
+
cell.animation?.cancel()
|
|
124
|
+
cell.animation = null
|
|
125
|
+
cell.top.textContent = char
|
|
126
|
+
cell.bottom.textContent = ""
|
|
127
|
+
cell.char = char
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function roll(cell: DigitCell, char: string, direction: -1 | 1) {
|
|
131
|
+
// jsdom has no Web Animations API. Nothing to roll there: show the digit.
|
|
132
|
+
if (typeof cell.strip.animate !== "function") {
|
|
133
|
+
settle(cell, char)
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
// Cancel, re-fill the slots, and start the new animation in one task, so
|
|
137
|
+
// the browser never paints the intermediate state: an interrupted roll
|
|
138
|
+
// restarts from wherever it had got to, towards the new target.
|
|
139
|
+
cell.animation?.cancel()
|
|
140
|
+
const from = cell.char ?? char
|
|
141
|
+
if (direction > 0) {
|
|
142
|
+
cell.top.textContent = from
|
|
143
|
+
cell.bottom.textContent = char
|
|
144
|
+
} else {
|
|
145
|
+
cell.top.textContent = char
|
|
146
|
+
cell.bottom.textContent = from
|
|
147
|
+
}
|
|
148
|
+
const keyframes =
|
|
149
|
+
direction > 0
|
|
150
|
+
? [{ transform: "translateY(0)" }, { transform: "translateY(-50%)" }]
|
|
151
|
+
: [{ transform: "translateY(-50%)" }, { transform: "translateY(0)" }]
|
|
152
|
+
const animation = cell.strip.animate(keyframes, {
|
|
153
|
+
duration: durationMs,
|
|
154
|
+
easing,
|
|
155
|
+
fill: "forwards",
|
|
156
|
+
})
|
|
157
|
+
cell.animation = animation
|
|
158
|
+
cell.char = char
|
|
159
|
+
animation.onfinish = () => {
|
|
160
|
+
if (cell.animation === animation) settle(cell, char)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function enter(cell: Cell) {
|
|
165
|
+
if (typeof cell.el.animate !== "function") return
|
|
166
|
+
cell.el.animate(
|
|
167
|
+
[
|
|
168
|
+
{ opacity: 0, transform: "translateY(0.35em)" },
|
|
169
|
+
{ opacity: 1, transform: "none" },
|
|
170
|
+
],
|
|
171
|
+
{ duration: durationMs, easing },
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
paint(formatted: string, direction: Direction) {
|
|
177
|
+
const { digits, suffix } = split(formatted)
|
|
178
|
+
const chars = digits.split("")
|
|
179
|
+
const wanted = chars.map((char, index) => ({
|
|
180
|
+
key: chars.length - 1 - index,
|
|
181
|
+
char,
|
|
182
|
+
}))
|
|
183
|
+
const nextOrder = `${wanted.map((cell) => cell.key).join(",")}|${suffix}`
|
|
184
|
+
const first = order === ""
|
|
185
|
+
|
|
186
|
+
if (nextOrder !== order) {
|
|
187
|
+
// The key set changed (the number gained or lost a digit): re-lay the
|
|
188
|
+
// row, reusing every cell whose key survived.
|
|
189
|
+
const fragment = document.createDocumentFragment()
|
|
190
|
+
const keep = new Set<number>()
|
|
191
|
+
for (const { key, char } of wanted) {
|
|
192
|
+
keep.add(key)
|
|
193
|
+
const separator = !/[0-9]/.test(char)
|
|
194
|
+
let cell = cells.get(key)
|
|
195
|
+
if (cell?.separator !== separator) {
|
|
196
|
+
cell = makeCell(separator)
|
|
197
|
+
cells.set(key, cell)
|
|
198
|
+
if (!first && direction !== 0) enter(cell)
|
|
199
|
+
}
|
|
200
|
+
fragment.append(cell.el)
|
|
201
|
+
}
|
|
202
|
+
for (const key of [...cells.keys()])
|
|
203
|
+
if (!keep.has(key)) cells.delete(key)
|
|
204
|
+
host.replaceChildren(fragment)
|
|
205
|
+
if (suffix) {
|
|
206
|
+
const el = span(SUFFIX_CLASS)
|
|
207
|
+
el.textContent = suffix
|
|
208
|
+
host.append(el)
|
|
209
|
+
}
|
|
210
|
+
order = nextOrder
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
for (const { key, char } of wanted) {
|
|
214
|
+
const cell = cells.get(key)
|
|
215
|
+
if (!cell || cell.char === char) continue // this digit did not change
|
|
216
|
+
if (cell.separator) {
|
|
217
|
+
cell.el.textContent = char
|
|
218
|
+
cell.char = char
|
|
219
|
+
} else if (cell.char === null || direction === 0) {
|
|
220
|
+
settle(cell, char)
|
|
221
|
+
} else {
|
|
222
|
+
roll(cell, char, direction)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
|
|
227
|
+
destroy() {
|
|
228
|
+
for (const cell of cells.values())
|
|
229
|
+
if (!cell.separator) cell.animation?.cancel()
|
|
230
|
+
},
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function DigitRoll({
|
|
235
|
+
value,
|
|
236
|
+
format,
|
|
237
|
+
className,
|
|
238
|
+
durationMs = ROLL_DURATION_MS,
|
|
239
|
+
easing = ROLL_EASING,
|
|
240
|
+
minIntervalMs = ROLL_MIN_INTERVAL_MS,
|
|
241
|
+
}: {
|
|
242
|
+
value: number
|
|
243
|
+
format: (value: number) => string
|
|
244
|
+
className?: string
|
|
245
|
+
durationMs?: number
|
|
246
|
+
easing?: string
|
|
247
|
+
minIntervalMs?: number
|
|
248
|
+
}) {
|
|
249
|
+
const hostRef = useRef<HTMLSpanElement>(null)
|
|
250
|
+
const rollRef = useRef<ReturnType<typeof createRoll> | null>(null)
|
|
251
|
+
// What the cells are showing, which is not `value` while an update is
|
|
252
|
+
// being held back by the coalescing window.
|
|
253
|
+
const shownRef = useRef(value)
|
|
254
|
+
const pendingRef = useRef<number | null>(null)
|
|
255
|
+
const lastRollAtRef = useRef(0)
|
|
256
|
+
const timerRef = useRef<number | undefined>(undefined)
|
|
257
|
+
const optionsRef = useRef({ format, durationMs, easing, minIntervalMs })
|
|
258
|
+
useEffect(() => {
|
|
259
|
+
optionsRef.current = { format, durationMs, easing, minIntervalMs }
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
useEffect(() => {
|
|
263
|
+
const host = hostRef.current
|
|
264
|
+
if (!host) return
|
|
265
|
+
const { format, durationMs, easing } = optionsRef.current
|
|
266
|
+
const roll = createRoll(host, { durationMs, easing })
|
|
267
|
+
rollRef.current = roll
|
|
268
|
+
// Never on mount: the first paint is the number, not a roll up to it.
|
|
269
|
+
roll.paint(format(shownRef.current), 0)
|
|
270
|
+
return () => {
|
|
271
|
+
clearTimeout(timerRef.current)
|
|
272
|
+
timerRef.current = undefined
|
|
273
|
+
pendingRef.current = null
|
|
274
|
+
rollRef.current = null
|
|
275
|
+
roll.destroy()
|
|
276
|
+
}
|
|
277
|
+
// Built once; every later value goes through the effect below.
|
|
278
|
+
}, [])
|
|
279
|
+
|
|
280
|
+
useEffect(() => {
|
|
281
|
+
const roll = rollRef.current
|
|
282
|
+
if (!roll || value === shownRef.current) return
|
|
283
|
+
|
|
284
|
+
const apply = (next: number) => {
|
|
285
|
+
lastRollAtRef.current = Date.now()
|
|
286
|
+
roll.paint(
|
|
287
|
+
optionsRef.current.format(next),
|
|
288
|
+
next >= shownRef.current ? 1 : -1,
|
|
289
|
+
)
|
|
290
|
+
shownRef.current = next
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const elapsed = Date.now() - lastRollAtRef.current
|
|
294
|
+
const wait = optionsRef.current.minIntervalMs - elapsed
|
|
295
|
+
if (wait <= 0) {
|
|
296
|
+
apply(value)
|
|
297
|
+
return
|
|
298
|
+
}
|
|
299
|
+
// Inside the window: hold this value (replacing any other one being
|
|
300
|
+
// held) and let the timer already running land the newest of them. The
|
|
301
|
+
// roll in flight is left alone.
|
|
302
|
+
pendingRef.current = value
|
|
303
|
+
timerRef.current ??= window.setTimeout(() => {
|
|
304
|
+
timerRef.current = undefined
|
|
305
|
+
const next = pendingRef.current
|
|
306
|
+
pendingRef.current = null
|
|
307
|
+
if (next !== null && next !== shownRef.current) apply(next)
|
|
308
|
+
}, wait)
|
|
309
|
+
}, [value])
|
|
310
|
+
|
|
311
|
+
return <span ref={hostRef} className={cn(className)} />
|
|
312
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { type LucideIcon } from "lucide-react"
|
|
2
|
+
import { type ReactNode, useId } from "react"
|
|
3
|
+
|
|
4
|
+
import { cn } from "@/lib/utils"
|
|
5
|
+
|
|
6
|
+
// A stretch of track behind the message: two rails and a run of ties. Two
|
|
7
|
+
// masks shape it. A horizontal one fades the ends so the line runs off the
|
|
8
|
+
// edge of the panel rather than stopping at it, and a radial one dissolves
|
|
9
|
+
// the middle so the message sits in a clear pocket while the rails stay
|
|
10
|
+
// legible on either side. The pocket is narrower below md so a phone card
|
|
11
|
+
// still shows a good run of track on each side of the message.
|
|
12
|
+
export function Track({ className }: { className?: string }) {
|
|
13
|
+
const id = useId()
|
|
14
|
+
return (
|
|
15
|
+
<svg
|
|
16
|
+
aria-hidden
|
|
17
|
+
className={cn(
|
|
18
|
+
"text-foreground/35 dark:text-foreground/40 pointer-events-none absolute inset-x-0 top-1/2 h-12 w-full -translate-y-1/2",
|
|
19
|
+
"[mask-composite:intersect]",
|
|
20
|
+
"[mask-image:linear-gradient(to_right,transparent,black_10%,black_90%,transparent),radial-gradient(ellipse_200px_160%_at_50%_50%,transparent_20%,black_100%)]",
|
|
21
|
+
"md:[mask-image:linear-gradient(to_right,transparent,black_10%,black_90%,transparent),radial-gradient(ellipse_300px_160%_at_50%_50%,transparent_35%,black_100%)]",
|
|
22
|
+
className,
|
|
23
|
+
)}
|
|
24
|
+
preserveAspectRatio="none"
|
|
25
|
+
>
|
|
26
|
+
<defs>
|
|
27
|
+
<pattern id={id} width="22" height="48" patternUnits="userSpaceOnUse">
|
|
28
|
+
<rect x="9" y="9" width="4" height="30" rx="1" fill="currentColor" />
|
|
29
|
+
</pattern>
|
|
30
|
+
</defs>
|
|
31
|
+
<rect width="100%" height="100%" fill={`url(#${id})`} />
|
|
32
|
+
<rect y="16" width="100%" height="2.5" fill="currentColor" />
|
|
33
|
+
<rect y="29.5" width="100%" height="2.5" fill="currentColor" />
|
|
34
|
+
</svg>
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type SignalAspect = "clear" | "caution" | "stop"
|
|
39
|
+
|
|
40
|
+
const ASPECT_CLASS: Record<SignalAspect, string> = {
|
|
41
|
+
clear: "text-live",
|
|
42
|
+
caution: "text-warning",
|
|
43
|
+
stop: "text-danger",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Lamps in their real order on a three-aspect head: stop on top, caution in
|
|
47
|
+
// the middle, clear at the bottom.
|
|
48
|
+
const LAMP_Y: Record<SignalAspect, number> = { stop: 8, caution: 17, clear: 26 }
|
|
49
|
+
const LAMP_ORDER: SignalAspect[] = ["stop", "caution", "clear"]
|
|
50
|
+
|
|
51
|
+
// A lineside signal standing just past the pocket, on the right. The post
|
|
52
|
+
// and hood take the track's ink; the one lit aspect uses the status
|
|
53
|
+
// vocabulary the rest of the UI already speaks (green = clear, amber =
|
|
54
|
+
// caution, rose = stop). An empty table is a clear line, so that is the
|
|
55
|
+
// default; pages whose empty state is a filter miss pass "caution".
|
|
56
|
+
export function Signal({
|
|
57
|
+
aspect,
|
|
58
|
+
className,
|
|
59
|
+
}: {
|
|
60
|
+
aspect: SignalAspect
|
|
61
|
+
className?: string
|
|
62
|
+
}) {
|
|
63
|
+
return (
|
|
64
|
+
<svg
|
|
65
|
+
aria-hidden
|
|
66
|
+
viewBox="0 0 20 64"
|
|
67
|
+
className={cn(
|
|
68
|
+
"pointer-events-none absolute top-1/2 h-16 w-5 -translate-y-[calc(50%+8px)]",
|
|
69
|
+
"left-[calc(50%+100px)] md:left-[calc(50%+150px)]",
|
|
70
|
+
className,
|
|
71
|
+
)}
|
|
72
|
+
>
|
|
73
|
+
<g className="text-foreground/35 dark:text-foreground/40">
|
|
74
|
+
<rect x="9" y="33" width="2" height="26" rx="1" fill="currentColor" />
|
|
75
|
+
<rect x="5" y="58" width="10" height="2" rx="1" fill="currentColor" />
|
|
76
|
+
<rect
|
|
77
|
+
x="4.75"
|
|
78
|
+
y="1.75"
|
|
79
|
+
width="10.5"
|
|
80
|
+
height="31.5"
|
|
81
|
+
rx="5.25"
|
|
82
|
+
fill="none"
|
|
83
|
+
stroke="currentColor"
|
|
84
|
+
strokeWidth="1.5"
|
|
85
|
+
/>
|
|
86
|
+
</g>
|
|
87
|
+
{LAMP_ORDER.map((lamp) =>
|
|
88
|
+
lamp === aspect ? (
|
|
89
|
+
<g key={lamp} className={ASPECT_CLASS[lamp]} data-lamp={lamp}>
|
|
90
|
+
<circle
|
|
91
|
+
cx="10"
|
|
92
|
+
cy={LAMP_Y[lamp]}
|
|
93
|
+
r="6"
|
|
94
|
+
fill="currentColor"
|
|
95
|
+
opacity="0.2"
|
|
96
|
+
className="blur-[2px]"
|
|
97
|
+
/>
|
|
98
|
+
<circle cx="10" cy={LAMP_Y[lamp]} r="3" fill="currentColor" />
|
|
99
|
+
</g>
|
|
100
|
+
) : (
|
|
101
|
+
<circle
|
|
102
|
+
key={lamp}
|
|
103
|
+
cx="10"
|
|
104
|
+
cy={LAMP_Y[lamp]}
|
|
105
|
+
r="3"
|
|
106
|
+
className="text-foreground/15"
|
|
107
|
+
fill="currentColor"
|
|
108
|
+
/>
|
|
109
|
+
),
|
|
110
|
+
)}
|
|
111
|
+
</svg>
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Empty state for tables and panels: the icon, title, optional description
|
|
116
|
+
// and action, set in the clear pocket of a track that runs behind them,
|
|
117
|
+
// with a signal beside the line showing the aspect.
|
|
118
|
+
export function EmptyState({
|
|
119
|
+
icon: Icon,
|
|
120
|
+
title,
|
|
121
|
+
description,
|
|
122
|
+
action,
|
|
123
|
+
signal = "clear",
|
|
124
|
+
className,
|
|
125
|
+
}: {
|
|
126
|
+
icon?: LucideIcon
|
|
127
|
+
title: ReactNode
|
|
128
|
+
description?: ReactNode
|
|
129
|
+
action?: ReactNode
|
|
130
|
+
signal?: SignalAspect
|
|
131
|
+
className?: string
|
|
132
|
+
}) {
|
|
133
|
+
return (
|
|
134
|
+
<div
|
|
135
|
+
className={cn(
|
|
136
|
+
"relative flex min-h-48 items-center justify-center overflow-hidden px-4 py-8 text-center",
|
|
137
|
+
className,
|
|
138
|
+
)}
|
|
139
|
+
aria-live="polite"
|
|
140
|
+
>
|
|
141
|
+
<Track />
|
|
142
|
+
<Signal aspect={signal} />
|
|
143
|
+
<div className="relative flex max-w-xs flex-col items-center gap-1.5">
|
|
144
|
+
{Icon && (
|
|
145
|
+
<span className="bg-muted text-muted-foreground mb-1 flex size-9 items-center justify-center rounded-md">
|
|
146
|
+
<Icon className="size-4" />
|
|
147
|
+
</span>
|
|
148
|
+
)}
|
|
149
|
+
<span className="text-sm font-medium">{title}</span>
|
|
150
|
+
{description && (
|
|
151
|
+
<span className="text-muted-foreground text-xs leading-relaxed">
|
|
152
|
+
{description}
|
|
153
|
+
</span>
|
|
154
|
+
)}
|
|
155
|
+
{action && <div className="mt-2">{action}</div>}
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
)
|
|
159
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { Link, usePage } from "@inertiajs/react"
|
|
2
|
+
import { Bookmark, ChevronRight } from "lucide-react"
|
|
3
|
+
import { useState } from "react"
|
|
4
|
+
|
|
5
|
+
import { isViewActive, samePage } from "@/components/railwatch/saved-views"
|
|
6
|
+
import {
|
|
7
|
+
Collapsible,
|
|
8
|
+
CollapsibleContent,
|
|
9
|
+
CollapsibleTrigger,
|
|
10
|
+
} from "@/components/ui/collapsible"
|
|
11
|
+
import {
|
|
12
|
+
SidebarGroup,
|
|
13
|
+
SidebarMenu,
|
|
14
|
+
SidebarMenuButton,
|
|
15
|
+
SidebarMenuItem,
|
|
16
|
+
SidebarMenuSub,
|
|
17
|
+
SidebarMenuSubButton,
|
|
18
|
+
SidebarMenuSubItem,
|
|
19
|
+
} from "@/components/ui/sidebar"
|
|
20
|
+
import { ENV_PAGE_GROUPS, envPages } from "@/lib/env-pages"
|
|
21
|
+
import { cn } from "@/lib/utils"
|
|
22
|
+
import type { SharedProps } from "@/types"
|
|
23
|
+
|
|
24
|
+
// Environment navigation in the sidebar: Overview at the top, then one
|
|
25
|
+
// collapsible section per page group (Activity, Monitoring, Settings). The
|
|
26
|
+
// section containing the current page starts open; the others start
|
|
27
|
+
// closed so the list stays short.
|
|
28
|
+
export function EnvNav() {
|
|
29
|
+
const page = usePage<SharedProps>()
|
|
30
|
+
const { environment } = page.props
|
|
31
|
+
const current = page.url.split("?")[0]
|
|
32
|
+
const [openOverride, setOpenOverride] = useState<
|
|
33
|
+
Record<string, boolean | undefined>
|
|
34
|
+
>({})
|
|
35
|
+
if (!environment) return null
|
|
36
|
+
|
|
37
|
+
const items = envPages(
|
|
38
|
+
environment.application_id,
|
|
39
|
+
environment.id,
|
|
40
|
+
page.props.window as string,
|
|
41
|
+
)
|
|
42
|
+
const pinnedViews = (page.props.saved_views ?? []).filter((v) => v.pinned)
|
|
43
|
+
const isCurrent = (href: string) => {
|
|
44
|
+
const path = href.split("?")[0]
|
|
45
|
+
return current === path || current.startsWith(path + "/")
|
|
46
|
+
}
|
|
47
|
+
const overview = items[0]
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<SidebarGroup className="p-0">
|
|
51
|
+
<SidebarMenu>
|
|
52
|
+
<SidebarMenuItem>
|
|
53
|
+
<SidebarMenuButton
|
|
54
|
+
asChild
|
|
55
|
+
isActive={current === overview.href.split("?")[0]}
|
|
56
|
+
tooltip={{ children: overview.title }}
|
|
57
|
+
>
|
|
58
|
+
<Link href={overview.href} prefetch>
|
|
59
|
+
{overview.icon && <overview.icon />}
|
|
60
|
+
<span>{overview.title}</span>
|
|
61
|
+
</Link>
|
|
62
|
+
</SidebarMenuButton>
|
|
63
|
+
</SidebarMenuItem>
|
|
64
|
+
{ENV_PAGE_GROUPS.map((group) => {
|
|
65
|
+
const groupItems = items.filter(
|
|
66
|
+
(i) => i.group === group.key && i !== overview,
|
|
67
|
+
)
|
|
68
|
+
const containsCurrent = groupItems.some((i) => isCurrent(i.href))
|
|
69
|
+
const open = openOverride[group.key] ?? containsCurrent
|
|
70
|
+
const GroupIcon = groupItems[0]?.icon
|
|
71
|
+
return (
|
|
72
|
+
<Collapsible
|
|
73
|
+
key={group.key}
|
|
74
|
+
open={open}
|
|
75
|
+
onOpenChange={(v) =>
|
|
76
|
+
setOpenOverride((o) => ({ ...o, [group.key]: v }))
|
|
77
|
+
}
|
|
78
|
+
className="group/collapsible"
|
|
79
|
+
>
|
|
80
|
+
<SidebarMenuItem>
|
|
81
|
+
<CollapsibleTrigger asChild>
|
|
82
|
+
<SidebarMenuButton
|
|
83
|
+
tooltip={{ children: group.title }}
|
|
84
|
+
className={cn(containsCurrent && "text-sidebar-foreground")}
|
|
85
|
+
>
|
|
86
|
+
{GroupIcon && <GroupIcon />}
|
|
87
|
+
<span>{group.title}</span>
|
|
88
|
+
<ChevronRight className="ml-auto size-3.5 opacity-60 transition-transform group-data-[state=open]/collapsible:rotate-90" />
|
|
89
|
+
</SidebarMenuButton>
|
|
90
|
+
</CollapsibleTrigger>
|
|
91
|
+
<CollapsibleContent>
|
|
92
|
+
<SidebarMenuSub className="mr-0 gap-0.5 pr-0">
|
|
93
|
+
{groupItems.map((item) => (
|
|
94
|
+
<SidebarMenuSubItem key={item.title}>
|
|
95
|
+
<SidebarMenuSubButton
|
|
96
|
+
asChild
|
|
97
|
+
isActive={isCurrent(item.href)}
|
|
98
|
+
>
|
|
99
|
+
<Link href={item.href} prefetch>
|
|
100
|
+
<span>{item.title}</span>
|
|
101
|
+
</Link>
|
|
102
|
+
</SidebarMenuSubButton>
|
|
103
|
+
{pinnedViews
|
|
104
|
+
.filter((view) => samePage(view.url, item.href))
|
|
105
|
+
.map((view) => (
|
|
106
|
+
<SidebarMenuSubButton
|
|
107
|
+
key={view.id}
|
|
108
|
+
asChild
|
|
109
|
+
size="sm"
|
|
110
|
+
className="pl-4"
|
|
111
|
+
isActive={isViewActive(page.url, view.url)}
|
|
112
|
+
>
|
|
113
|
+
<Link href={view.url}>
|
|
114
|
+
<Bookmark className="opacity-60" />
|
|
115
|
+
<span>{view.name}</span>
|
|
116
|
+
</Link>
|
|
117
|
+
</SidebarMenuSubButton>
|
|
118
|
+
))}
|
|
119
|
+
</SidebarMenuSubItem>
|
|
120
|
+
))}
|
|
121
|
+
</SidebarMenuSub>
|
|
122
|
+
</CollapsibleContent>
|
|
123
|
+
</SidebarMenuItem>
|
|
124
|
+
</Collapsible>
|
|
125
|
+
)
|
|
126
|
+
})}
|
|
127
|
+
</SidebarMenu>
|
|
128
|
+
</SidebarGroup>
|
|
129
|
+
)
|
|
130
|
+
}
|