@hot-updater/console 1.0.0-rc.2 → 1.0.0-rc.21

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 (285) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/ConsoleFeatureUnavailable-D0LPL1Gy.js +1 -0
  3. package/.output/public/assets/HashValueDisplay-BmuB4FW-.js +1 -0
  4. package/.output/public/assets/InsightsInfo--v4SC07c.js +1 -0
  5. package/.output/public/assets/InsightsPageHeader-dU83CAQY.js +1 -0
  6. package/.output/public/assets/NotFoundPage-B5mhGG2a.js +1 -0
  7. package/.output/public/assets/api-D-vOVlQ4.js +1 -0
  8. package/.output/public/assets/api-keys-Csh8ARKU.js +1 -0
  9. package/.output/public/assets/api-keys-OMPUoA_A.js +1 -0
  10. package/.output/public/assets/arrow-left-Bwq9X1eS.js +1 -0
  11. package/.output/public/assets/arrow-up-right-BkOVn4if.js +1 -0
  12. package/.output/public/assets/card-C_7d6O9a.js +1 -0
  13. package/.output/public/assets/console-features-api-C1jRZTGw.js +1 -0
  14. package/.output/public/assets/dialog-DE8wFN_P.js +1 -0
  15. package/.output/public/assets/dist-C7ENGzpS.js +1 -0
  16. package/.output/public/assets/empty-CVd5NlQD.js +1 -0
  17. package/.output/public/assets/field-C06q1iC9.js +1 -0
  18. package/.output/public/assets/index-BeWlQtO6.js +11 -0
  19. package/.output/public/assets/input-group-Bi61biIA.js +1 -0
  20. package/.output/public/assets/insights-BjnAtC0f.js +2 -0
  21. package/.output/public/assets/insights-D86m11yH.js +42 -0
  22. package/.output/public/assets/insights-DpYstMoY.js +1 -0
  23. package/.output/public/assets/insights-recovery-rpc-BIm5_ALc.js +1 -0
  24. package/.output/public/assets/insights-search-KIF7mRtB.js +1 -0
  25. package/.output/public/assets/insights-usage-rpc-VHj8vyBt.js +1 -0
  26. package/.output/public/assets/insights_.distribution-ADcrX48c.js +2 -0
  27. package/.output/public/assets/insights_.distribution-CuuJUN6Y.js +1 -0
  28. package/.output/public/assets/insights_.distribution-DJRjuXsO.js +1 -0
  29. package/.output/public/assets/installations-BSBBzqWs.js +2 -0
  30. package/.output/public/assets/installations-DqIE6WcO.js +1 -0
  31. package/.output/public/assets/installations-UsELbDvw.js +1 -0
  32. package/.output/public/assets/loader-circle-CnCBxzHp.js +1 -0
  33. package/.output/public/assets/mutation-Csp7xUNh.js +1 -0
  34. package/.output/public/assets/preload-helper-B-cOmpLH.js +1 -0
  35. package/.output/public/assets/refresh-cw-FF1NtzlQ.js +1 -0
  36. package/.output/public/assets/routes-CoN9XHP_.js +2 -0
  37. package/.output/public/assets/routes-DT1jB0Zi.js +1 -0
  38. package/.output/public/assets/sidebar-D8mJRF15.js +3 -0
  39. package/.output/public/assets/styles-Bwt7v6U5.css +2 -0
  40. package/.output/public/assets/table-WBgfvgXg.js +1 -0
  41. package/.output/public/assets/useCompositeListItem-CloBmDwn.js +1 -0
  42. package/.output/public/assets/useMutation-DO8cvsTx.js +1 -0
  43. package/.output/public/assets/useOpenInteractionType-ReGr9GzJ.js +1 -0
  44. package/.output/public/assets/valueToPercent-BvPAi4bP.js +1 -0
  45. package/.output/server/{__23tanstack-start-server-fn-resolver-B7ioJhtE.mjs → __23tanstack-start-server-fn-resolver-DMRD4e4f.mjs} +51 -47
  46. package/.output/server/_libs/@base-ui/react+[...].mjs +7044 -3622
  47. package/.output/server/_libs/@reduxjs/toolkit+[...].mjs +1554 -0
  48. package/.output/server/_libs/@tanstack/react-router+[...].mjs +3803 -4023
  49. package/.output/server/_libs/@tanstack/react-router-ssr-query+[...].mjs +141 -98
  50. package/.output/server/_libs/d3-array.mjs +231 -0
  51. package/.output/server/_libs/d3-color.mjs +330 -0
  52. package/.output/server/_libs/d3-format.mjs +252 -0
  53. package/.output/server/_libs/d3-interpolate.mjs +207 -0
  54. package/.output/server/_libs/d3-path.mjs +77 -0
  55. package/.output/server/_libs/d3-scale+[...].mjs +1907 -0
  56. package/.output/server/_libs/d3-shape.mjs +818 -0
  57. package/.output/server/_libs/decimal.js-light.mjs +884 -0
  58. package/.output/server/_libs/es-toolkit.mjs +1159 -0
  59. package/.output/server/_libs/eventemitter3.mjs +236 -0
  60. package/.output/server/_libs/immer.mjs +532 -0
  61. package/.output/server/_libs/lucide-react.mjs +46 -32
  62. package/.output/server/_libs/next-themes.mjs +2 -2
  63. package/.output/server/_libs/react-redux+[...].mjs +205 -0
  64. package/.output/server/_libs/recharts+[...].mjs +14350 -0
  65. package/.output/server/_libs/sonner.mjs +2 -2
  66. package/.output/server/_libs/tanstack__history.mjs +68 -41
  67. package/.output/server/_libs/tanstack__query-core.mjs +1618 -309
  68. package/.output/server/_libs/tanstack__react-query.mjs +262 -22
  69. package/.output/server/_libs/verkit.mjs +99 -1
  70. package/.output/server/_runtime.mjs +10 -1
  71. package/.output/server/_ssr/ConsoleFeatureUnavailable-Du7jNPNS.mjs +58 -0
  72. package/.output/server/_ssr/{HashValueDisplay-DwpGLfrl.mjs → HashValueDisplay-GQvFkXoi.mjs} +3 -3
  73. package/.output/server/_ssr/InsightsInfo-Do7QSaFR.mjs +37 -0
  74. package/.output/server/_ssr/InsightsPageHeader-DSUWUWRc.mjs +154 -0
  75. package/.output/server/_ssr/NotFoundPage-DsF4Vrhg.mjs +37 -0
  76. package/.output/server/_ssr/api-BEiVs0H7.mjs +203 -0
  77. package/.output/server/_ssr/api-keys-BrvBWGRf.mjs +5 -0
  78. package/.output/server/_ssr/{api-keys-CZeTPQys.mjs → api-keys-K5v32nf7.mjs} +49 -9
  79. package/.output/server/_ssr/api-keys-rpc-B96bBllm.mjs +52 -0
  80. package/.output/server/_ssr/api-rpc-DcUe280B.mjs +296 -0
  81. package/.output/server/_ssr/{auth-rpc-DUaeOWKK.mjs → auth-rpc-C68W56DP.mjs} +6 -6
  82. package/.output/server/_ssr/auth.server-DM1J6NwR.mjs +5712 -0
  83. package/.output/server/_ssr/bundleActivity-CpuY9CTP.mjs +19 -0
  84. package/.output/server/_ssr/card-CPAEKx0w.mjs +49 -0
  85. package/.output/server/_ssr/config-nsIcsHh8.mjs +15 -0
  86. package/.output/server/_ssr/config.server-DDCZk3Sq.mjs +36 -0
  87. package/.output/server/_ssr/console-access-Da-2Qd7q.mjs +17 -0
  88. package/.output/server/_ssr/console-features-Dzu2iUGD.mjs +55 -0
  89. package/.output/server/_ssr/console-features-api-xAsSgFa_.mjs +43 -0
  90. package/.output/server/_ssr/console-features-rpc-qARGpB24.mjs +19 -0
  91. package/.output/server/_ssr/{createServerFn-B2hcXoPM.mjs → createMiddleware-BdBf_ezN.mjs} +67 -56
  92. package/.output/server/_ssr/{createServerRpc-DB29C_5p.mjs → createServerRpc-B_y2lrwA.mjs} +2 -2
  93. package/.output/server/_ssr/{databaseJsonValue-d6I3nAS4.mjs → databaseJsonValue-Bhxciy7Z.mjs} +28 -5
  94. package/.output/server/_ssr/databaseRows-DwMAjaF8.mjs +74 -0
  95. package/.output/server/_ssr/{dialog-DS-nw-pA.mjs → dialog-q__BJ7mQ.mjs} +2 -2
  96. package/.output/server/_ssr/{dist-CyzPaI5v.mjs → dist-Cxo9ghYv.mjs} +140 -6
  97. package/.output/server/_ssr/{downloadBundle-CNnYxYO2.mjs → downloadBundle-BW9I-pMQ.mjs} +10 -10
  98. package/.output/server/_ssr/empty-DcBTAooP.mjs +49 -0
  99. package/.output/server/_ssr/{empty-plugin-adapters-B5QvGpEl.mjs → empty-plugin-adapters-CYdinE-i.mjs} +1 -1
  100. package/.output/server/_ssr/{table-CnZmon_Z.mjs → field-QCDBVpXA.mjs} +63 -51
  101. package/.output/server/_ssr/getBundleChildren-DIAGCF4a.mjs +22 -0
  102. package/.output/server/_ssr/{input-group-BpsnmvZw.mjs → input-group-hCk2nUoU.mjs} +2 -2
  103. package/.output/server/_ssr/insights-BfBO0RFr.mjs +1238 -0
  104. package/.output/server/_ssr/insights-DsAU3-TB.mjs +14 -0
  105. package/.output/server/_ssr/insights-RIfg447W.mjs +5 -0
  106. package/.output/server/_ssr/insights-api-CDn-QQHU.mjs +105 -0
  107. package/.output/server/_ssr/insights-failures-BBGMhuUr.mjs +44 -0
  108. package/.output/server/_ssr/insights-failures-rpc-rA2dctK2.mjs +21 -0
  109. package/.output/server/_ssr/insights-recovery-Dpk6Mfb9.mjs +28 -0
  110. package/.output/server/_ssr/insights-recovery-rpc-BCaF-xSd.mjs +46 -0
  111. package/.output/server/_ssr/insights-recovery-rpc-CcgYyUHk.mjs +20 -0
  112. package/.output/server/_ssr/{insights-rpc-38DujK0G.mjs → insights-rpc-keycmwzw.mjs} +22 -14
  113. package/.output/server/_ssr/insights-search-BRg5VsNe.mjs +25 -0
  114. package/.output/server/_ssr/insights-usage-cbw9n2Fq.mjs +29 -0
  115. package/.output/server/_ssr/insights-usage-rpc-BKgV1QDf.mjs +29 -0
  116. package/.output/server/_ssr/insights-usage-rpc-D-JYachy.mjs +41 -0
  117. package/.output/server/_ssr/insightsRecovery-B7oMc8d8.mjs +45 -0
  118. package/.output/server/_ssr/insightsUsage-Oa7kk5ek.mjs +56 -0
  119. package/.output/server/_ssr/insights_.distribution-BIqOqRW0.mjs +335 -0
  120. package/.output/server/_ssr/insights_.distribution-CuUrDa7P.mjs +5 -0
  121. package/.output/server/_ssr/insights_.distribution-RT_6x_ud.mjs +14 -0
  122. package/.output/server/_ssr/{installations-DiVKEF5T.mjs → installations-BO5CjX2z.mjs} +517 -54
  123. package/.output/server/_ssr/installations-BjaHiIfh.mjs +5 -0
  124. package/.output/server/_ssr/installations-Dt5luA-N.mjs +69 -0
  125. package/.output/server/_ssr/listReleases-BitHzOYB.mjs +79 -0
  126. package/.output/server/_ssr/releaseManagement-B3e7I8J9.mjs +11 -0
  127. package/.output/server/_ssr/{router-B9wG_9f8.mjs → router-DfVAixxe.mjs} +135 -139
  128. package/.output/server/_ssr/{routes-D7Yh_jfj.mjs → routes-C6LstNp8.mjs} +176 -455
  129. package/.output/server/_ssr/routes-Q3Uhmwwz.mjs +292 -0
  130. package/.output/server/_ssr/runtime.server-fL0o6I2b.mjs +1459 -0
  131. package/.output/server/_ssr/schema-BKY9SqaY.mjs +25 -0
  132. package/.output/server/_ssr/select-CUlInmjk.mjs +107 -0
  133. package/.output/server/_ssr/server-C43wq9ft.mjs +8330 -0
  134. package/.output/server/_ssr/{createSsrRpc-DjWNJ0uc.mjs → sidebar-DKbx2ach.mjs} +117 -63
  135. package/.output/server/_ssr/ssr.mjs +1 -1
  136. package/.output/server/_ssr/start-BXUOv_n8.mjs +4 -0
  137. package/.output/server/_ssr/table-NGWV8B-S.mjs +53 -0
  138. package/.output/server/_ssr/updateFailures-Jwe2Baab.mjs +24 -0
  139. package/.output/server/_ssr/{uuidv7-D0QFkPL_.mjs → uuidv7-C8bwiZr8.mjs} +2 -2
  140. package/.output/server/_tanstack-start-manifest_v-WQ5qGn8n.mjs +124 -0
  141. package/.output/server/index.mjs +267 -162
  142. package/LICENSE +13 -14
  143. package/README.md +82 -72
  144. package/lib/index.d.mts +23 -11
  145. package/lib/vite-internal.mjs +13 -3
  146. package/lib/vite.d.mts +2 -0
  147. package/package.json +14 -14
  148. package/src/components/AppSidebar.tsx +100 -71
  149. package/src/components/ConsoleAccessPage.tsx +5 -1
  150. package/src/components/ConsoleFeatureUnavailable.tsx +80 -0
  151. package/src/components/NotFoundPage.tsx +7 -7
  152. package/src/components/features/FeatureSlots.tsx +99 -0
  153. package/src/components/features/api-keys/apiKeysFeatureUi.tsx +15 -0
  154. package/src/components/features/bundles/BundleMetadata.tsx +8 -5
  155. package/src/components/features/bundles/RolloutCohortsDialog.tsx +1 -1
  156. package/src/components/features/channels/ChannelManagementDialog.tsx +2 -5
  157. package/src/components/features/insights/AppUsage.tsx +370 -0
  158. package/src/components/features/insights/EstimatedCount.tsx +18 -0
  159. package/src/components/features/insights/EventDetails.tsx +117 -37
  160. package/src/components/features/insights/EventHistoryCard.tsx +112 -26
  161. package/src/components/features/insights/EventHistoryList.tsx +2 -2
  162. package/src/components/features/insights/InsightsControls.tsx +265 -102
  163. package/src/components/features/insights/InsightsErrorAlert.tsx +14 -1
  164. package/src/components/features/insights/InsightsInfo.tsx +48 -0
  165. package/src/components/features/insights/InsightsOverview.tsx +176 -173
  166. package/src/components/features/insights/InsightsPageHeader.tsx +35 -20
  167. package/src/components/features/insights/InsightsPeriodSelector.tsx +89 -0
  168. package/src/components/features/insights/InstallationHistoryCard.tsx +52 -10
  169. package/src/components/features/insights/InstallationMatchesCard.tsx +1 -1
  170. package/src/components/features/insights/ReleaseActivity.tsx +188 -0
  171. package/src/components/features/insights/ReportingDevicesSummary.tsx +65 -0
  172. package/src/components/features/insights/UpdateFailures.tsx +319 -0
  173. package/src/components/features/insights/insightsFeatureUi.tsx +35 -0
  174. package/src/components/features/releases/ReleaseEditorSheet.tsx +30 -7
  175. package/src/components/ui/card.tsx +1 -1
  176. package/src/components/ui/empty.tsx +101 -0
  177. package/src/components/ui/scroll-area.tsx +52 -0
  178. package/src/components/ui/sidebar.tsx +22 -29
  179. package/src/components/ui/tabs.tsx +81 -0
  180. package/src/index.ts +26 -19
  181. package/src/lib/api-keys-api.ts +0 -28
  182. package/src/lib/api-keys-rpc.ts +27 -38
  183. package/src/lib/api-rpc.ts +189 -266
  184. package/src/lib/api.ts +19 -102
  185. package/src/lib/bundle-activity.ts +31 -0
  186. package/src/lib/console-access.ts +20 -0
  187. package/src/lib/console-features-api.ts +68 -0
  188. package/src/lib/console-features-rpc.ts +13 -0
  189. package/src/lib/console-features.ts +91 -0
  190. package/src/lib/insights-api.ts +37 -1
  191. package/src/lib/insights-failures-rpc.ts +21 -0
  192. package/src/lib/insights-failures.ts +129 -0
  193. package/src/lib/insights-recovery-rpc.ts +51 -0
  194. package/src/lib/insights-recovery.ts +68 -0
  195. package/src/lib/insights-retention.ts +21 -0
  196. package/src/lib/insights-rpc.ts +30 -12
  197. package/src/lib/insights-search.ts +58 -0
  198. package/src/lib/insights-usage-rpc.ts +29 -0
  199. package/src/lib/insights-usage.ts +74 -0
  200. package/src/lib/insights-view.ts +38 -2
  201. package/src/lib/scroll-restoration.ts +21 -0
  202. package/src/lib/server/adminInsights.ts +124 -0
  203. package/src/lib/server/auth.server.ts +13 -5
  204. package/src/lib/server/bundleActivity.ts +37 -0
  205. package/src/lib/server/config.server.ts +21 -74
  206. package/src/lib/server/console-feature-apis.ts +59 -0
  207. package/src/lib/server/console-runtime.server.ts +82 -38
  208. package/src/lib/server/downloadBundle.ts +16 -15
  209. package/src/lib/server/getBundleChildren.ts +31 -122
  210. package/src/lib/server/insightsRecovery.ts +55 -0
  211. package/src/lib/server/insightsUsage.ts +63 -0
  212. package/src/lib/server/listReleases.ts +109 -44
  213. package/src/lib/server/releaseReachability.ts +8 -7
  214. package/src/lib/server/runtime.server.ts +120 -31
  215. package/src/lib/server/updateFailures.ts +31 -0
  216. package/src/routeTree.gen.ts +44 -44
  217. package/src/router.tsx +3 -2
  218. package/src/routes/-installations-search.ts +32 -8
  219. package/src/routes/-releases-search.ts +76 -29
  220. package/src/routes/__root.tsx +10 -4
  221. package/src/routes/api/bundles/$bundleId/download.ts +2 -5
  222. package/src/routes/api-keys.tsx +5 -2
  223. package/src/routes/index.tsx +167 -104
  224. package/src/routes/insights.tsx +100 -59
  225. package/src/routes/insights_.distribution.tsx +398 -0
  226. package/src/routes/installations.tsx +98 -41
  227. package/src/vite-internal.ts +15 -3
  228. package/src/vite.ts +2 -0
  229. package/.output/public/assets/HashValueDisplay-CB0Eskit.js +0 -1
  230. package/.output/public/assets/InsightsPageHeader-Car5LNiZ.js +0 -1
  231. package/.output/public/assets/api-keys-BzKocqRy.js +0 -1
  232. package/.output/public/assets/api-keys-api-BHcODB7q.js +0 -1
  233. package/.output/public/assets/card-Dhr_eFYj.js +0 -1
  234. package/.output/public/assets/chart-no-axes-combined-Dy4WJv2U.js +0 -1
  235. package/.output/public/assets/composite-Bp98A0Ld.js +0 -1
  236. package/.output/public/assets/createServerFn-CA8fs7QD.js +0 -3
  237. package/.output/public/assets/dialog-CUTrcPlB.js +0 -1
  238. package/.output/public/assets/dist-C_NydL0e.js +0 -1
  239. package/.output/public/assets/index-DdAz-CXv.js +0 -10
  240. package/.output/public/assets/input-group-cSOG4wh_.js +0 -1
  241. package/.output/public/assets/insights-C0PBSZ-n.js +0 -1
  242. package/.output/public/assets/installations-CE33wgbY.js +0 -2
  243. package/.output/public/assets/installations-CStZja8x.js +0 -1
  244. package/.output/public/assets/lazyRouteComponent-Ccp3bwBY.js +0 -1
  245. package/.output/public/assets/link-CI8idAUi.js +0 -1
  246. package/.output/public/assets/loader-circle-BoxLrsOu.js +0 -1
  247. package/.output/public/assets/preload-helper-CQ36nxok.js +0 -1
  248. package/.output/public/assets/refresh-cw-Bqc72UZG.js +0 -1
  249. package/.output/public/assets/routes-DGVXdzpw.js +0 -2
  250. package/.output/public/assets/routes-DTlwOr8I.js +0 -1
  251. package/.output/public/assets/shield-check-uK3MHWJZ.js +0 -1
  252. package/.output/public/assets/signing-CMf4aqDO.js +0 -1
  253. package/.output/public/assets/styles-BCvbtKsm.css +0 -2
  254. package/.output/public/assets/table-2vcV89QB.js +0 -1
  255. package/.output/public/assets/useCompositeListItem-CHLITE_4.js +0 -1
  256. package/.output/public/assets/x-DCd0j8c6.js +0 -1
  257. package/.output/server/_ssr/InsightsPageHeader-Df4Brj8u.mjs +0 -484
  258. package/.output/server/_ssr/api-keys-api-BUPQVSpk.mjs +0 -60
  259. package/.output/server/_ssr/api-keys-rpc-Cs7tS5_j.mjs +0 -136
  260. package/.output/server/_ssr/api-rpc-tAupfGQm.mjs +0 -1399
  261. package/.output/server/_ssr/auth.server-DW71WzsD.mjs +0 -47
  262. package/.output/server/_ssr/bundle-signing-rpc-BhnBw0-d.mjs +0 -16
  263. package/.output/server/_ssr/bundle-signing.server-C4NW5mWx.mjs +0 -12
  264. package/.output/server/_ssr/card-BRiPmi0C.mjs +0 -108
  265. package/.output/server/_ssr/config-Dv8qLDZ0.mjs +0 -13
  266. package/.output/server/_ssr/config.server-afDuAwaG.mjs +0 -368
  267. package/.output/server/_ssr/databaseRows-5bCRTxm-.mjs +0 -126
  268. package/.output/server/_ssr/deleteBundle-BTGwBgzx.mjs +0 -66
  269. package/.output/server/_ssr/getBundleChildren-C0vh4ZvE.mjs +0 -50
  270. package/.output/server/_ssr/insights-BdS364Xu.mjs +0 -460
  271. package/.output/server/_ssr/insightsContract-fjJDBeEE.mjs +0 -303
  272. package/.output/server/_ssr/installations-CtA59MQG.mjs +0 -29
  273. package/.output/server/_ssr/routes-CKrrS2q2.mjs +0 -37
  274. package/.output/server/_ssr/runtime.server-DacU1TBV.mjs +0 -438
  275. package/.output/server/_ssr/server-D_C2rMzf.mjs +0 -7468
  276. package/.output/server/_ssr/signing-Dz1WkZOf.mjs +0 -133
  277. package/.output/server/_ssr/start-DpViQGnt.mjs +0 -4
  278. package/.output/server/_tanstack-start-manifest_v-2MjTyxil.mjs +0 -97
  279. package/src/components/features/insights/InsightsBundleReports.tsx +0 -51
  280. package/src/components/features/signing/BundleSigningPage.tsx +0 -139
  281. package/src/lib/bundle-signing-api.ts +0 -14
  282. package/src/lib/bundle-signing-rpc.ts +0 -16
  283. package/src/lib/server/bundle-signing.server.ts +0 -23
  284. package/src/lib/server/deleteBundle.ts +0 -145
  285. package/src/routes/signing.tsx +0 -37
@@ -1,13 +1,17 @@
1
+ import type { ReleaseFilter } from "@hot-updater/plugin-core";
2
+
1
3
  export interface ReleaseSearch {
4
+ /** Releases newer than this id: the previous page. */
2
5
  afterReleaseId?: string;
6
+ /** Releases older than this id: the next page. */
3
7
  beforeReleaseId?: string;
4
8
  bundleId?: string;
5
9
  channelId?: string;
6
10
  enabled?: boolean;
7
- page?: number;
8
11
  platform?: "ios" | "android";
9
12
  releaseId?: string;
10
- targetAppVersion?: string;
13
+ /** One catalog scope: a channel, platform, and target kind. */
14
+ scopeKey?: string;
11
15
  }
12
16
 
13
17
  const optionalString = (value: unknown) =>
@@ -15,15 +19,14 @@ const optionalString = (value: unknown) =>
15
19
  ? value.trim()
16
20
  : undefined;
17
21
 
22
+ /**
23
+ * The search a URL holds, reduced to one of the filter sets the release
24
+ * indexes serve: a bundle, a scope (with enabled), or a channel with its
25
+ * platform (with enabled). A channel without a platform lists iOS.
26
+ */
18
27
  export function validateReleaseSearch(
19
28
  search: Record<string, unknown>,
20
29
  ): ReleaseSearch {
21
- const parsedPage =
22
- typeof search.page === "number"
23
- ? search.page
24
- : typeof search.page === "string"
25
- ? Number(search.page)
26
- : undefined;
27
30
  const beforeReleaseId = optionalString(search.beforeReleaseId);
28
31
  const afterReleaseId = beforeReleaseId
29
32
  ? undefined
@@ -34,41 +37,85 @@ export function validateReleaseSearch(
34
37
  : search.enabled === false || search.enabled === "false"
35
38
  ? false
36
39
  : undefined;
40
+ const bundleId = optionalString(search.bundleId);
41
+ const scopeKey = bundleId ? undefined : optionalString(search.scopeKey);
42
+ const channelId =
43
+ bundleId || scopeKey ? undefined : optionalString(search.channelId);
44
+ const platform =
45
+ channelId === undefined
46
+ ? undefined
47
+ : search.platform === "android"
48
+ ? "android"
49
+ : "ios";
37
50
 
38
51
  return {
39
52
  afterReleaseId,
40
53
  beforeReleaseId,
41
- bundleId: optionalString(search.bundleId),
42
- channelId: optionalString(search.channelId),
43
- enabled,
44
- page:
45
- parsedPage !== undefined && Number.isInteger(parsedPage) && parsedPage > 1
46
- ? parsedPage
47
- : undefined,
48
- platform:
49
- search.platform === "ios" || search.platform === "android"
50
- ? search.platform
51
- : undefined,
54
+ bundleId,
55
+ channelId,
56
+ enabled: scopeKey || channelId ? enabled : undefined,
57
+ platform,
52
58
  releaseId: optionalString(search.releaseId),
53
- targetAppVersion: optionalString(search.targetAppVersion),
59
+ scopeKey,
54
60
  };
55
61
  }
56
62
 
63
+ export function releaseFilterOf(search: ReleaseSearch): ReleaseFilter {
64
+ const enabled =
65
+ search.enabled === undefined ? {} : { enabled: search.enabled };
66
+ if (search.bundleId) return { kind: "bundle", bundleId: search.bundleId };
67
+ if (search.scopeKey) {
68
+ return { kind: "scope", scopeKey: search.scopeKey, ...enabled };
69
+ }
70
+ if (search.channelId && search.platform) {
71
+ return {
72
+ kind: "channelPlatform",
73
+ channelId: search.channelId,
74
+ platform: search.platform,
75
+ ...enabled,
76
+ };
77
+ }
78
+ return { kind: "all" };
79
+ }
80
+
81
+ export function hasReleaseFilters(search: ReleaseSearch): boolean {
82
+ return releaseFilterOf(search).kind !== "all";
83
+ }
84
+
85
+ /**
86
+ * A new filter starts from the first page. A bundle or scope filter replaces
87
+ * the others, and choosing a channel or platform clears them.
88
+ */
57
89
  export function updateReleaseFilters(
58
90
  current: ReleaseSearch,
59
91
  filters: Partial<
60
92
  Pick<
61
93
  ReleaseSearch,
62
- "bundleId" | "channelId" | "enabled" | "platform" | "targetAppVersion"
94
+ "bundleId" | "channelId" | "enabled" | "platform" | "scopeKey"
63
95
  >
64
96
  >,
65
97
  ): ReleaseSearch {
66
- return {
67
- ...current,
68
- ...filters,
69
- afterReleaseId: undefined,
70
- beforeReleaseId: undefined,
71
- page: undefined,
72
- releaseId: undefined,
73
- };
98
+ const next = { ...current, ...filters };
99
+ if (filters.bundleId)
100
+ return validateReleaseSearch({ bundleId: next.bundleId });
101
+ if (filters.scopeKey) {
102
+ return validateReleaseSearch({
103
+ scopeKey: next.scopeKey,
104
+ enabled: next.enabled,
105
+ });
106
+ }
107
+ if ("channelId" in filters || "platform" in filters) {
108
+ return validateReleaseSearch({
109
+ channelId: next.channelId,
110
+ enabled: next.enabled,
111
+ platform: next.platform,
112
+ });
113
+ }
114
+ return validateReleaseSearch({
115
+ bundleId: next.bundleId,
116
+ channelId: next.channelId,
117
+ enabled: next.enabled,
118
+ platform: next.platform,
119
+ scopeKey: next.scopeKey,
120
+ });
74
121
  }
@@ -18,6 +18,7 @@ import {
18
18
  getConsoleAccessRpc,
19
19
  getConsoleAuthProvidersRpc,
20
20
  } from "@/lib/auth-rpc";
21
+ import { consoleFeaturesQueryOptions } from "@/lib/console-features-api";
21
22
 
22
23
  import appCss from "../styles.css?url";
23
24
 
@@ -60,11 +61,16 @@ export const Route = createRootRouteWithContext<{
60
61
  ],
61
62
  }),
62
63
 
63
- loader: async () => {
64
+ loader: async ({ context }) => {
64
65
  const [access, providers] = await Promise.all([
65
66
  getConsoleAccessRpc(),
66
67
  getConsoleAuthProvidersRpc(),
67
68
  ]);
69
+ // The authorized layout's navigation shows only the features on, so they
70
+ // load with it; the query keeps them for the visit.
71
+ if (access.status === "authorized") {
72
+ await context.queryClient.prefetchQuery(consoleFeaturesQueryOptions());
73
+ }
68
74
  return { access, providers };
69
75
  },
70
76
 
@@ -140,10 +146,10 @@ function RootLayout() {
140
146
  return <ConsoleAccessPage access={access} providers={providers} />;
141
147
  }
142
148
 
143
- return <AuthorizedConsole />;
149
+ return <AuthorizedConsole canSignOut={providers.length > 0} />;
144
150
  }
145
151
 
146
- function AuthorizedConsole() {
152
+ function AuthorizedConsole({ canSignOut }: { canSignOut: boolean }) {
147
153
  useEffect(() => {
148
154
  if (
149
155
  import.meta.env.DEV &&
@@ -155,7 +161,7 @@ function AuthorizedConsole() {
155
161
  }, []);
156
162
  return (
157
163
  <SidebarProvider>
158
- <AppSidebar />
164
+ <AppSidebar canSignOut={canSignOut} />
159
165
  <SidebarInset className="min-h-0 min-w-0 overflow-hidden">
160
166
  <Outlet />
161
167
  </SidebarInset>
@@ -6,11 +6,8 @@ export const Route = createFileRoute("/api/bundles/$bundleId/download")({
6
6
  GET: async ({ params, request }) => {
7
7
  const { prepareConfig } = await import("@/lib/server/config.server");
8
8
  const { downloadBundle } = await import("@/lib/server/downloadBundle");
9
- const { databaseClient, storagePlugin } = await prepareConfig(request);
10
- return downloadBundle(params.bundleId, {
11
- databaseClient,
12
- storagePlugin,
13
- });
9
+ const { core, storage } = await prepareConfig(request);
10
+ return downloadBundle(params.bundleId, { core, storage });
14
11
  },
15
12
  },
16
13
  },
@@ -1,12 +1,15 @@
1
1
  import { createFileRoute } from "@tanstack/react-router";
2
2
  import { KeyRound } from "lucide-react";
3
3
 
4
+ import { ConsoleFeatureUnavailable } from "@/components/ConsoleFeatureUnavailable";
4
5
  import { ApiKeysPage } from "@/components/features/api-keys/ApiKeysPage";
5
6
  import { SidebarTrigger } from "@/components/ui/sidebar";
6
- import { ensureApiKeyRouteAccess } from "@/lib/api-keys-api";
7
+ import { requireConsoleFeature } from "@/lib/console-features-api";
7
8
 
8
9
  export const Route = createFileRoute("/api-keys")({
9
- beforeLoad: ({ context }) => ensureApiKeyRouteAccess(context.queryClient),
10
+ beforeLoad: ({ context }) =>
11
+ requireConsoleFeature(context.queryClient, "apiKeys"),
12
+ notFoundComponent: ConsoleFeatureUnavailable,
10
13
  component: ApiKeysRoute,
11
14
  });
12
15
 
@@ -20,6 +20,7 @@ import { ChannelBadge } from "@/components/ChannelBadge";
20
20
  import { EnabledStatusIcon } from "@/components/EnabledStatusIcon";
21
21
  import { BundleChildrenPanel } from "@/components/features/bundles/BundleChildrenPanel";
22
22
  import { ChannelManagementDialog } from "@/components/features/channels/ChannelManagementDialog";
23
+ import { useReleaseColumns } from "@/components/features/FeatureSlots";
23
24
  import { ReleaseEditorSheet } from "@/components/features/releases/ReleaseEditorSheet";
24
25
  import { ReleaseStateBadge } from "@/components/features/releases/ReleaseStateBadge";
25
26
  import { HashValueDisplay } from "@/components/HashValueDisplay";
@@ -28,7 +29,6 @@ import { RolloutPercentageBadge } from "@/components/RolloutPercentageBadge";
28
29
  import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
29
30
  import { Badge } from "@/components/ui/badge";
30
31
  import { Button, buttonVariants } from "@/components/ui/button";
31
- import { Input } from "@/components/ui/input";
32
32
  import {
33
33
  Select,
34
34
  SelectContent,
@@ -59,6 +59,8 @@ import type { ReleaseListRow } from "@/lib/server/releaseReachability";
59
59
  import { cn } from "@/lib/utils";
60
60
 
61
61
  import {
62
+ hasReleaseFilters,
63
+ releaseFilterOf,
62
64
  type ReleaseSearch,
63
65
  updateReleaseFilters,
64
66
  validateReleaseSearch,
@@ -66,10 +68,14 @@ import {
66
68
 
67
69
  const PAGE_SIZE = 20;
68
70
  const platformFilterItems = [
69
- { label: "All Platforms", value: "all" },
70
71
  { label: "iOS", value: "ios" },
71
72
  { label: "Android", value: "android" },
72
73
  ];
74
+ const statusFilterItems = [
75
+ { label: "Any Status", value: "all" },
76
+ { label: "Enabled", value: "enabled" },
77
+ { label: "Disabled", value: "disabled" },
78
+ ];
73
79
  const dateFormatter = new Intl.DateTimeFormat(undefined, {
74
80
  dateStyle: "medium",
75
81
  timeStyle: "short",
@@ -96,16 +102,9 @@ function BundleFilterToolbar({
96
102
  onManageChannels: () => void;
97
103
  search: ReleaseSearch;
98
104
  }) {
99
- const [targetAppVersion, setTargetAppVersion] = useState(
100
- search.targetAppVersion ?? "",
101
- );
102
- const hasFilters = Boolean(
103
- search.bundleId ||
104
- search.channelId ||
105
- search.enabled !== undefined ||
106
- search.platform ||
107
- search.targetAppVersion,
108
- );
105
+ const hasFilters = hasReleaseFilters(search);
106
+ // Releases list by channel and platform together: the index serves that pair.
107
+ const channelSelected = search.channelId !== undefined;
109
108
  const channelFilterItems = [
110
109
  { label: "All Channels", value: "all" },
111
110
  ...channels.map((channel) => ({
@@ -113,14 +112,6 @@ function BundleFilterToolbar({
113
112
  value: channel.id,
114
113
  })),
115
114
  ];
116
- const applyTargetAppVersion = () => {
117
- const value = targetAppVersion.trim();
118
- onChange({ targetAppVersion: value || undefined });
119
- };
120
-
121
- useEffect(() => {
122
- setTargetAppVersion(search.targetAppVersion ?? "");
123
- }, [search.targetAppVersion]);
124
115
 
125
116
  return (
126
117
  <header className="sticky top-0 z-10 flex shrink-0 flex-wrap items-center gap-2 border-b bg-background px-3 py-3 sm:h-12 sm:flex-nowrap sm:bg-card/70 sm:px-4 sm:py-0 sm:backdrop-blur-sm">
@@ -131,65 +122,85 @@ function BundleFilterToolbar({
131
122
  <span className="text-xs font-medium">Filters</span>
132
123
  </div>
133
124
  <Select
134
- items={platformFilterItems}
125
+ items={channelFilterItems}
135
126
  onValueChange={(value) =>
136
127
  onChange({
137
- platform:
138
- value === "all" ? undefined : (value as "ios" | "android"),
128
+ channelId: value === null || value === "all" ? undefined : value,
139
129
  })
140
130
  }
141
- value={search.platform ?? "all"}
131
+ value={search.channelId ?? "all"}
132
+ >
133
+ <SelectTrigger
134
+ aria-label="Channel"
135
+ className="h-8 w-[calc(50%-0.25rem)] min-w-[132px] text-xs sm:w-[140px]"
136
+ >
137
+ <SelectValue />
138
+ </SelectTrigger>
139
+ <SelectContent>
140
+ <SelectGroup>
141
+ <SelectItem value="all">All Channels</SelectItem>
142
+ {channels.map((channel) => (
143
+ <SelectItem key={channel.id} value={channel.id}>
144
+ {channel.name}
145
+ </SelectItem>
146
+ ))}
147
+ </SelectGroup>
148
+ </SelectContent>
149
+ </Select>
150
+ <Select
151
+ disabled={!channelSelected}
152
+ items={platformFilterItems}
153
+ onValueChange={(value) =>
154
+ onChange({ platform: value === "android" ? "android" : "ios" })
155
+ }
156
+ value={search.platform ?? "ios"}
142
157
  >
143
158
  <SelectTrigger
144
159
  aria-label="Platform"
145
160
  className="h-8 w-[calc(50%-0.25rem)] min-w-[132px] text-xs sm:w-[140px]"
161
+ title={channelSelected ? undefined : "Choose a channel first"}
146
162
  >
147
163
  <SelectValue />
148
164
  </SelectTrigger>
149
165
  <SelectContent>
150
166
  <SelectGroup>
151
- <SelectItem value="all">All Platforms</SelectItem>
152
167
  <SelectItem value="ios">iOS</SelectItem>
153
168
  <SelectItem value="android">Android</SelectItem>
154
169
  </SelectGroup>
155
170
  </SelectContent>
156
171
  </Select>
157
- <Input
158
- aria-label="Target app version"
159
- className="h-8 w-full min-w-[132px] text-xs sm:w-[160px]"
160
- onBlur={applyTargetAppVersion}
161
- onChange={(event) => setTargetAppVersion(event.target.value)}
162
- onKeyDown={(event) => {
163
- if (event.key === "Enter") {
164
- applyTargetAppVersion();
165
- }
166
- }}
167
- placeholder="Target version"
168
- value={targetAppVersion}
169
- />
170
172
  <Select
171
- items={channelFilterItems}
173
+ disabled={!channelSelected && search.scopeKey === undefined}
174
+ items={statusFilterItems}
172
175
  onValueChange={(value) =>
173
176
  onChange({
174
- channelId: value === null || value === "all" ? undefined : value,
177
+ enabled:
178
+ value === "enabled"
179
+ ? true
180
+ : value === "disabled"
181
+ ? false
182
+ : undefined,
175
183
  })
176
184
  }
177
- value={search.channelId ?? "all"}
185
+ value={
186
+ search.enabled === undefined
187
+ ? "all"
188
+ : search.enabled
189
+ ? "enabled"
190
+ : "disabled"
191
+ }
178
192
  >
179
193
  <SelectTrigger
180
- aria-label="Channel"
194
+ aria-label="Status"
181
195
  className="h-8 w-[calc(50%-0.25rem)] min-w-[132px] text-xs sm:w-[140px]"
182
196
  >
183
197
  <SelectValue />
184
198
  </SelectTrigger>
185
199
  <SelectContent>
186
200
  <SelectGroup>
187
- <SelectItem value="all">All Channels</SelectItem>
188
- {channels.map((channel) => (
189
- <SelectItem key={channel.id} value={channel.id}>
190
- {channel.name}
191
- </SelectItem>
192
- ))}
201
+ <SelectItem value="all">Any Status</SelectItem>
202
+ <SelectItem value="enabled">Enabled</SelectItem>
203
+ <SelectItem value="disabled">Disabled</SelectItem>
193
204
  </SelectGroup>
194
205
  </SelectContent>
195
206
  </Select>
@@ -215,6 +226,19 @@ function BundleFilterToolbar({
215
226
  </button>
216
227
  </Badge>
217
228
  ) : null}
229
+ {search.scopeKey ? (
230
+ <Badge className="max-w-48 gap-1" variant="secondary">
231
+ Target filter
232
+ <button
233
+ aria-label="Clear target filter"
234
+ className="rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
235
+ onClick={() => onChange({ scopeKey: undefined })}
236
+ type="button"
237
+ >
238
+ <X className="size-3" />
239
+ </button>
240
+ </Badge>
241
+ ) : null}
218
242
  {hasFilters ? (
219
243
  <Button
220
244
  className="h-8 px-2 text-xs text-muted-foreground hover:text-foreground sm:ml-auto"
@@ -374,13 +398,8 @@ function BundlesPage() {
374
398
  const releasesQuery = useReleasesQuery({
375
399
  afterReleaseId: search.afterReleaseId,
376
400
  beforeReleaseId: search.beforeReleaseId,
377
- bundleId: search.bundleId,
378
- channelId: search.channelId,
379
- enabled: search.enabled,
401
+ filter: releaseFilterOf(search),
380
402
  limit: PAGE_SIZE,
381
- page: search.page,
382
- platform: search.platform,
383
- targetAppVersion: search.targetAppVersion,
384
403
  });
385
404
  const channelsQuery = useChannelsQuery();
386
405
  const channels = channelsQuery.data ?? [];
@@ -394,7 +413,19 @@ function BundlesPage() {
394
413
  );
395
414
  const patchCountsQuery = useBundleChildCountsQuery(bundleIds);
396
415
  const patchCountsByBundleId = patchCountsQuery.data ?? {};
397
- const pagination = releasesQuery.data?.pagination;
416
+ const nextCursor = releasesQuery.data?.next;
417
+ const previousCursor = releasesQuery.data?.previous;
418
+ const slotReleases = releases.flatMap((release) => {
419
+ const channel = channels.find(
420
+ (item) => item.id === release.channel_id,
421
+ )?.name;
422
+ return channel
423
+ ? [{ releaseId: release.id, platform: release.platform, channel }]
424
+ : [];
425
+ });
426
+ // Features add columns, such as release activity where the console reads it.
427
+ const featureColumns = useReleaseColumns();
428
+ const columnCount = 10 + featureColumns.length;
398
429
  const channelNames = new Map(
399
430
  channels.map((channel) => [channel.id, channel.name]),
400
431
  );
@@ -423,33 +454,31 @@ function BundlesPage() {
423
454
  }
424
455
  changeFilters({ bundleId: bundle.id });
425
456
  };
426
- const currentPage = pagination?.currentPage ?? 1;
427
- const startEntry =
428
- releases.length === 0 ? 0 : (currentPage - 1) * PAGE_SIZE + 1;
429
- const endEntry = startEntry === 0 ? 0 : startEntry + releases.length - 1;
430
- const previousPage = currentPage - 1;
431
- const firstReleaseId = releases[0]?.id;
432
- const lastReleaseId = releases.at(-1)?.id;
433
- const previousSearch: ReleaseSearch | null =
434
- pagination?.hasPreviousPage && firstReleaseId
435
- ? {
436
- ...search,
437
- afterReleaseId: firstReleaseId,
438
- beforeReleaseId: undefined,
439
- page: previousPage > 1 ? previousPage : undefined,
440
- releaseId: undefined,
441
- }
442
- : null;
443
- const nextSearch: ReleaseSearch | null =
444
- pagination?.hasNextPage && lastReleaseId
445
- ? {
446
- ...search,
447
- afterReleaseId: undefined,
448
- beforeReleaseId: lastReleaseId,
449
- page: currentPage + 1,
450
- releaseId: undefined,
451
- }
452
- : null;
457
+ // Pages go by key: newer or older than an edge release, never by number.
458
+ const onLaterPage =
459
+ search.afterReleaseId !== undefined || search.beforeReleaseId !== undefined;
460
+ const previousSearch: ReleaseSearch | null = previousCursor
461
+ ? {
462
+ ...search,
463
+ afterReleaseId: previousCursor,
464
+ beforeReleaseId: undefined,
465
+ releaseId: undefined,
466
+ }
467
+ : null;
468
+ const nextSearch: ReleaseSearch | null = nextCursor
469
+ ? {
470
+ ...search,
471
+ afterReleaseId: undefined,
472
+ beforeReleaseId: nextCursor,
473
+ releaseId: undefined,
474
+ }
475
+ : null;
476
+ const newestSearch: ReleaseSearch = {
477
+ ...search,
478
+ afterReleaseId: undefined,
479
+ beforeReleaseId: undefined,
480
+ releaseId: undefined,
481
+ };
453
482
 
454
483
  return (
455
484
  <div className="flex h-svh min-h-0 min-w-0 flex-col">
@@ -556,6 +585,22 @@ function BundlesPage() {
556
585
  : "—"}
557
586
  </dd>
558
587
  </div>
588
+ {featureColumns.map(({ Cell, feature, label }) => (
589
+ <div
590
+ className="col-span-2 rounded-md bg-muted/40 p-3"
591
+ key={feature}
592
+ >
593
+ <dt className="mb-2 text-muted-foreground">
594
+ {label}
595
+ </dt>
596
+ <dd>
597
+ <Cell
598
+ releaseId={release.id}
599
+ releases={slotReleases}
600
+ />
601
+ </dd>
602
+ </div>
603
+ ))}
559
604
  <div className="col-span-2 flex flex-wrap items-center gap-2 rounded-md bg-muted/40 p-3">
560
605
  <ReleaseStateBadge release={release} />
561
606
  {release.should_force_update ? (
@@ -596,13 +641,11 @@ function BundlesPage() {
596
641
  })
597
642
  ) : (
598
643
  <p className="p-8 text-center text-sm text-muted-foreground">
599
- {search.bundleId ||
600
- search.channelId ||
601
- search.enabled !== undefined ||
602
- search.platform ||
603
- search.targetAppVersion
604
- ? "No bundles match these filters."
605
- : "No bundles yet. Deploy an update to see it here."}
644
+ {onLaterPage
645
+ ? "No more bundles on this side."
646
+ : hasReleaseFilters(search)
647
+ ? "No bundles match these filters."
648
+ : "No bundles yet. Deploy an update to see it here."}
606
649
  </p>
607
650
  )}
608
651
  </div>
@@ -618,6 +661,9 @@ function BundlesPage() {
618
661
  <TableHead>Enabled</TableHead>
619
662
  <TableHead>Force update</TableHead>
620
663
  <TableHead>Rollout</TableHead>
664
+ {featureColumns.map(({ feature, label }) => (
665
+ <TableHead key={feature}>{label}</TableHead>
666
+ ))}
621
667
  <TableHead>Message</TableHead>
622
668
  <TableHead>Created</TableHead>
623
669
  </TableRow>
@@ -626,7 +672,7 @@ function BundlesPage() {
626
672
  {releasesQuery.isPending
627
673
  ? Array.from({ length: 7 }, (_, index) => (
628
674
  <TableRow key={index}>
629
- <TableCell colSpan={10}>
675
+ <TableCell colSpan={columnCount}>
630
676
  <Skeleton className="h-8 w-full" />
631
677
  </TableCell>
632
678
  </TableRow>
@@ -783,6 +829,14 @@ function BundlesPage() {
783
829
  percentage={release.rollout_cohort_count / 10}
784
830
  />
785
831
  </TableCell>
832
+ {featureColumns.map(({ Cell, feature }) => (
833
+ <TableCell key={feature}>
834
+ <Cell
835
+ releaseId={release.id}
836
+ releases={slotReleases}
837
+ />
838
+ </TableCell>
839
+ ))}
786
840
  <TableCell
787
841
  className="text-sm text-muted-foreground"
788
842
  title={release.message ?? undefined}
@@ -804,7 +858,7 @@ function BundlesPage() {
804
858
  <TableRow className="hover:bg-transparent">
805
859
  <TableCell
806
860
  className="border-t-0 p-0"
807
- colSpan={10}
861
+ colSpan={columnCount}
808
862
  >
809
863
  <ReleaseBundleChildrenPanel
810
864
  onDetailClick={openChildBundle}
@@ -821,15 +875,13 @@ function BundlesPage() {
821
875
  <TableRow>
822
876
  <TableCell
823
877
  className="h-40 text-center text-muted-foreground"
824
- colSpan={10}
878
+ colSpan={columnCount}
825
879
  >
826
- {search.bundleId ||
827
- search.channelId ||
828
- search.enabled !== undefined ||
829
- search.platform ||
830
- search.targetAppVersion
831
- ? "No bundles match these filters."
832
- : "No bundles yet. Deploy an update to see it here."}
880
+ {onLaterPage
881
+ ? "No more bundles on this side."
882
+ : hasReleaseFilters(search)
883
+ ? "No bundles match these filters."
884
+ : "No bundles yet. Deploy an update to see it here."}
833
885
  </TableCell>
834
886
  </TableRow>
835
887
  ) : null}
@@ -845,13 +897,23 @@ function BundlesPage() {
845
897
  className="flex flex-col gap-3 px-2 sm:flex-row sm:items-center sm:justify-between"
846
898
  >
847
899
  <p className="text-xs font-medium text-muted-foreground">
848
- Showing <span className="text-foreground">{startEntry}</span> to{" "}
849
- <span className="text-foreground">{endEntry}</span> entries
900
+ <span className="text-foreground">{releases.length}</span>{" "}
901
+ {releases.length === 1 ? "entry" : "entries"}, newest first
850
902
  </p>
851
903
  <div className="flex flex-wrap items-center gap-3 sm:justify-end">
852
- <p className="text-xs font-medium text-muted-foreground">
853
- Page <span className="text-foreground">{currentPage}</span>
854
- </p>
904
+ {onLaterPage ? (
905
+ <Link
906
+ className={buttonVariants({
907
+ className: "h-8 flex-1 px-3 text-xs sm:flex-none",
908
+ size: "sm",
909
+ variant: "ghost",
910
+ })}
911
+ search={newestSearch}
912
+ to="/"
913
+ >
914
+ Newest
915
+ </Link>
916
+ ) : null}
855
917
  {previousSearch ? (
856
918
  <Link
857
919
  className={buttonVariants({
@@ -910,6 +972,7 @@ function BundlesPage() {
910
972
  onOpenChange={(open) =>
911
973
  !open && go({ ...search, releaseId: undefined }, false)
912
974
  }
975
+ onShowScope={(scopeKey) => changeFilters({ scopeKey })}
913
976
  open={Boolean(search.releaseId)}
914
977
  releaseId={search.releaseId ?? ""}
915
978
  />