@hot-updater/console 0.36.7 → 1.0.0-rc.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 (266) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/AnalyticsCapabilityContext-BCke57-7.js +1 -0
  3. package/.output/public/assets/BundleActivityChart-CnsBe_1J.js +42 -0
  4. package/.output/public/assets/BundleIdDisplay-Dky75zLU.js +1 -0
  5. package/.output/public/assets/alert-C-1KvDW8.js +1 -0
  6. package/.output/public/assets/analytics-Bqz0c-BT.js +1 -0
  7. package/.output/public/assets/analytics-api-CJdrTLLO.js +1 -0
  8. package/.output/public/assets/api-B_Rh-uzC.js +1 -0
  9. package/.output/public/assets/api-keys-BurCFhzH.js +1 -0
  10. package/.output/public/assets/api-keys-api-BEDJdQg3.js +1 -0
  11. package/.output/public/assets/badge-Bf0fZ-Gx.js +1 -0
  12. package/.output/public/assets/chart-no-axes-combined-FaYMGmQS.js +1 -0
  13. package/.output/public/assets/createServerFn-gM2-LFBy.js +3 -0
  14. package/.output/public/assets/dialog-BS60BRr2.js +1 -0
  15. package/.output/public/assets/dist-DspNKfMj.js +1 -0
  16. package/.output/public/assets/download-O-yNBc9H.js +1 -0
  17. package/.output/public/assets/field-Buq148-A.js +1 -0
  18. package/.output/public/assets/index-kFhd2S78.js +10 -0
  19. package/.output/public/assets/installations-BrQ-od41.js +2 -0
  20. package/.output/public/assets/installations-CGlZ3ROz.js +1 -0
  21. package/.output/public/assets/lazyRouteComponent-rPzgA7S-.js +1 -0
  22. package/.output/public/assets/loader-circle-DCYF-wJl.js +1 -0
  23. package/.output/public/assets/preload-helper-TKOBYGYD.js +1 -0
  24. package/.output/public/assets/refresh-cw-n8XYbPPj.js +1 -0
  25. package/.output/public/assets/routes-CrdokJ80.js +1 -0
  26. package/.output/public/assets/routes-DcQnVajO.js +2 -0
  27. package/.output/public/assets/search-DLhbWw3W.js +1 -0
  28. package/.output/public/assets/shield-check-DE_hVuHu.js +1 -0
  29. package/.output/public/assets/signing-HRexfjsH.js +1 -0
  30. package/.output/public/assets/styles-eB4EtvFS.css +2 -0
  31. package/.output/public/assets/table-D8CLieHX.js +1 -0
  32. package/.output/public/assets/textarea-YJLTVAoq.js +1 -0
  33. package/.output/public/assets/useMutation-JsGVjQMY.js +1 -0
  34. package/.output/public/assets/useOpenInteractionType-4cRD5xO0.js +1 -0
  35. package/.output/server/__23tanstack-start-server-fn-resolver-DBP9TQ7K.mjs +146 -0
  36. package/.output/server/_libs/@base-ui/react+[...].mjs +16124 -0
  37. package/.output/server/_libs/@reduxjs/toolkit+[...].mjs +1554 -0
  38. package/.output/server/_libs/@tanstack/react-router+[...].mjs +41 -10
  39. package/.output/server/_libs/@tanstack/react-router-ssr-query+[...].mjs +132 -0
  40. package/.output/server/_libs/d3-array.mjs +231 -0
  41. package/.output/server/_libs/d3-color.mjs +330 -0
  42. package/.output/server/_libs/d3-format.mjs +252 -0
  43. package/.output/server/_libs/d3-interpolate.mjs +207 -0
  44. package/.output/server/_libs/d3-path.mjs +77 -0
  45. package/.output/server/_libs/d3-scale+[...].mjs +1907 -0
  46. package/.output/server/_libs/d3-shape.mjs +818 -0
  47. package/.output/server/_libs/decimal.js-light.mjs +884 -0
  48. package/.output/server/_libs/es-toolkit.mjs +1134 -0
  49. package/.output/server/_libs/eventemitter3.mjs +236 -0
  50. package/.output/server/_libs/immer.mjs +532 -0
  51. package/.output/server/_libs/lucide-react.mjs +141 -13
  52. package/.output/server/_libs/next-themes.mjs +2 -2
  53. package/.output/server/_libs/react-redux+[...].mjs +205 -0
  54. package/.output/server/_libs/recharts+[...].mjs +14117 -0
  55. package/.output/server/_libs/sonner.mjs +2 -2
  56. package/.output/server/_libs/tanstack__query-core.mjs +114 -1
  57. package/.output/server/_libs/tanstack__react-query.mjs +3 -4
  58. package/.output/server/_runtime.mjs +10 -1
  59. package/.output/server/_ssr/AnalyticsCapabilityContext-ui4DX8GO.mjs +15 -0
  60. package/.output/server/_ssr/BundleActivityChart-BiB9ZORH.mjs +288 -0
  61. package/.output/server/_ssr/BundleIdDisplay-Czk6iR6p.mjs +13 -0
  62. package/.output/server/_ssr/alert-Dim3MGY5.mjs +86 -0
  63. package/.output/server/_ssr/analytics-DtMTn8fn.mjs +858 -0
  64. package/.output/server/_ssr/analytics-api-Cg1M5v_H.mjs +71 -0
  65. package/.output/server/_ssr/analytics-input-fRRRn0uA.mjs +96 -0
  66. package/.output/server/_ssr/analytics-rpc-DY5uXvHT.mjs +155 -0
  67. package/.output/server/_ssr/api-BTSv1tZb.mjs +282 -0
  68. package/.output/server/_ssr/api-keys-BnM_o2nS.mjs +300 -0
  69. package/.output/server/_ssr/api-keys-api-CIywsSF5.mjs +60 -0
  70. package/.output/server/_ssr/api-keys-rpc-BSp1wkIV.mjs +136 -0
  71. package/.output/server/_ssr/api-rpc-cG5wd_tO.mjs +1468 -0
  72. package/.output/server/_ssr/auth-BF8mbAgF.mjs +21 -0
  73. package/.output/server/_ssr/auth-rpc-BrJ6obHJ.mjs +24 -0
  74. package/.output/server/_ssr/auth.server-DwKoZoQM.mjs +50 -0
  75. package/.output/server/_ssr/badge-DCxbEeof.mjs +26 -0
  76. package/.output/server/_ssr/bundle-signing-rpc-COoQhW8E.mjs +16 -0
  77. package/.output/server/_ssr/bundle-signing.server-D5u8KkMp.mjs +51 -0
  78. package/.output/server/_ssr/config-Dv8qLDZ0.mjs +13 -0
  79. package/.output/server/_ssr/config.server-DEiLhKmy.mjs +361 -0
  80. package/.output/server/_ssr/{createServerFn-DzU0k62V.mjs → createServerFn-B2hcXoPM.mjs} +1 -1
  81. package/.output/server/_ssr/createServerRpc-DB29C_5p.mjs +12 -0
  82. package/.output/server/_ssr/{sidebar-Cv_H0_Jv.mjs → createSsrRpc-9P0S0lJ3.mjs} +68 -50
  83. package/.output/server/_ssr/databaseRows-CwbNwYSl.mjs +166 -0
  84. package/.output/server/_ssr/deleteBundle-Cu_XOMt0.mjs +66 -0
  85. package/.output/server/_ssr/dialog-CQuRMNZ4.mjs +170 -0
  86. package/.output/server/_ssr/dist-BVSLTpoP.mjs +250 -0
  87. package/.output/server/_ssr/downloadBundle-CNnYxYO2.mjs +24 -0
  88. package/.output/server/_ssr/{empty-plugin-adapters-B4HCzgmM.mjs → empty-plugin-adapters-B5QvGpEl.mjs} +1 -1
  89. package/.output/server/_ssr/field-DRIXKjZG.mjs +74 -0
  90. package/.output/server/_ssr/{getBundleChildren-CVtSiH5t.mjs → getBundleChildren-BcRMFoDF.mjs} +10 -18
  91. package/.output/server/_ssr/installations-B-azgAvX.mjs +19 -0
  92. package/.output/server/_ssr/installations-Ctvzp2JH.mjs +535 -0
  93. package/.output/server/_ssr/router-C-KUBfOi.mjs +513 -0
  94. package/.output/server/_ssr/routes-BM4ht9jb.mjs +2286 -0
  95. package/.output/server/_ssr/routes-Z7TwC6nC.mjs +37 -0
  96. package/.output/server/_ssr/runtime.server-awTLiiXW.mjs +527 -0
  97. package/.output/server/_ssr/server-BjzmvXnO.mjs +7468 -0
  98. package/.output/server/_ssr/signing-k8GxyBDF.mjs +202 -0
  99. package/.output/server/_ssr/ssr.mjs +4 -5740
  100. package/.output/server/_ssr/start-DpViQGnt.mjs +4 -0
  101. package/.output/server/_ssr/table-v1yCvYo7.mjs +53 -0
  102. package/.output/server/_ssr/textarea-DYHnskhb.mjs +14 -0
  103. package/.output/server/_ssr/uuidv7-BbJ-9D_I.mjs +31 -0
  104. package/.output/server/_tanstack-start-manifest_v-CH4StzJu.mjs +108 -0
  105. package/.output/server/index.mjs +256 -53
  106. package/README.md +29 -8
  107. package/lib/index.d.mts +36 -0
  108. package/lib/index.mjs +4 -0
  109. package/lib/vite-internal.mjs +127 -0
  110. package/lib/vite.d.mts +10 -0
  111. package/lib/vite.mjs +5 -0
  112. package/package.json +51 -29
  113. package/public/apple-touch-icon.png +0 -0
  114. package/public/favicon-16x16.png +0 -0
  115. package/public/favicon-32x32.png +0 -0
  116. package/public/favicon.ico +0 -0
  117. package/public/logo.svg +1 -0
  118. package/public/manifest.json +30 -0
  119. package/public/robots.txt +3 -0
  120. package/src/components/AppSidebar.tsx +159 -0
  121. package/src/components/BundleIdDisplay.tsx +18 -0
  122. package/src/components/ChannelBadge.tsx +31 -0
  123. package/src/components/ConsoleAccessPage.tsx +122 -0
  124. package/src/components/EnabledStatusIcon.tsx +54 -0
  125. package/src/components/HashValueDisplay.tsx +85 -0
  126. package/src/components/HotUpdaterLogo.tsx +30 -0
  127. package/src/components/NotFoundPage.tsx +33 -0
  128. package/src/components/PlatformIcon.tsx +39 -0
  129. package/src/components/RolloutPercentageBadge.tsx +28 -0
  130. package/src/components/ThemeProvider.tsx +116 -0
  131. package/src/components/TimestampDisplay.tsx +41 -0
  132. package/src/components/features/analytics/ActivityChart.tsx +150 -0
  133. package/src/components/features/analytics/AnalyticsCapabilityContext.tsx +24 -0
  134. package/src/components/features/analytics/AnalyticsControls.tsx +115 -0
  135. package/src/components/features/analytics/AnalyticsErrorAlert.tsx +21 -0
  136. package/src/components/features/analytics/AnalyticsOverview.tsx +208 -0
  137. package/src/components/features/analytics/BundleDistribution.tsx +133 -0
  138. package/src/components/features/analytics/BundleSelector.tsx +114 -0
  139. package/src/components/features/analytics/InstallationHistoryCard.tsx +248 -0
  140. package/src/components/features/analytics/InstallationMatchesCard.tsx +132 -0
  141. package/src/components/features/analytics/InstallationPageHeader.tsx +138 -0
  142. package/src/components/features/analytics/InstallationPagination.tsx +48 -0
  143. package/src/components/features/analytics/InstallationSearch.tsx +230 -0
  144. package/src/components/features/analytics/UpdateOutcomes.tsx +147 -0
  145. package/src/components/features/api-keys/ApiKeysPage.tsx +377 -0
  146. package/src/components/features/bundles/BundleActivityChart.tsx +193 -0
  147. package/src/components/features/bundles/BundleAnalyticsSummary.tsx +123 -0
  148. package/src/components/features/bundles/BundleChildrenPanel.tsx +201 -0
  149. package/src/components/features/bundles/BundleMetadata.tsx +136 -0
  150. package/src/components/features/bundles/RolloutCohortsDialog.tsx +201 -0
  151. package/src/components/features/channels/ChannelManagementDialog.tsx +228 -0
  152. package/src/components/features/releases/ReleaseEditorSheet.tsx +879 -0
  153. package/src/components/features/releases/ReleaseStateBadge.tsx +20 -0
  154. package/src/components/features/signing/BundleSigningPage.tsx +247 -0
  155. package/src/components/ui/alert-dialog.tsx +185 -0
  156. package/src/components/ui/alert.tsx +76 -0
  157. package/src/components/ui/badge.tsx +49 -0
  158. package/src/components/ui/button.tsx +58 -0
  159. package/src/components/ui/card.tsx +89 -0
  160. package/src/components/ui/chart.tsx +371 -0
  161. package/src/components/ui/combobox.tsx +303 -0
  162. package/src/components/ui/dialog.tsx +156 -0
  163. package/src/components/ui/dropdown-menu.tsx +270 -0
  164. package/src/components/ui/field.tsx +235 -0
  165. package/src/components/ui/input-group.tsx +147 -0
  166. package/src/components/ui/input.tsx +19 -0
  167. package/src/components/ui/label.tsx +25 -0
  168. package/src/components/ui/progress.tsx +84 -0
  169. package/src/components/ui/select.tsx +205 -0
  170. package/src/components/ui/separator.tsx +23 -0
  171. package/src/components/ui/sheet.tsx +132 -0
  172. package/src/components/ui/sidebar.tsx +779 -0
  173. package/src/components/ui/skeleton.tsx +13 -0
  174. package/src/components/ui/slider.tsx +56 -0
  175. package/src/components/ui/sonner.tsx +45 -0
  176. package/src/components/ui/switch.tsx +25 -0
  177. package/src/components/ui/table.tsx +114 -0
  178. package/src/components/ui/textarea.tsx +18 -0
  179. package/src/components/ui/toggle-group.tsx +89 -0
  180. package/src/components/ui/toggle.tsx +43 -0
  181. package/src/components/ui/tooltip.tsx +66 -0
  182. package/src/hooks/use-mobile.ts +30 -0
  183. package/src/index.ts +48 -0
  184. package/src/lib/analytics-api.ts +145 -0
  185. package/src/lib/analytics-error.ts +19 -0
  186. package/src/lib/analytics-input.ts +187 -0
  187. package/src/lib/analytics-overview.ts +170 -0
  188. package/src/lib/analytics-rpc.ts +218 -0
  189. package/src/lib/api-keys-api.ts +69 -0
  190. package/src/lib/api-keys-rpc.ts +87 -0
  191. package/src/lib/api-rpc.ts +478 -0
  192. package/src/lib/api.ts +444 -0
  193. package/src/lib/auth-rpc.ts +16 -0
  194. package/src/lib/bundle-signing-api.ts +14 -0
  195. package/src/lib/bundle-signing-rpc.ts +16 -0
  196. package/src/lib/constants.ts +1 -0
  197. package/src/lib/git.ts +51 -0
  198. package/src/lib/server/auth.server.ts +43 -0
  199. package/src/lib/server/bundle-signing.server.ts +100 -0
  200. package/src/lib/server/config.server.ts +109 -0
  201. package/src/lib/server/console-runtime.server.ts +70 -0
  202. package/src/lib/server/deleteBundle.ts +145 -0
  203. package/src/lib/server/downloadBundle.ts +46 -0
  204. package/src/lib/server/getBundleChildren.ts +130 -0
  205. package/src/lib/server/listReleases.ts +61 -0
  206. package/src/lib/server/releaseActivity.ts +27 -0
  207. package/src/lib/server/releaseReachability.ts +95 -0
  208. package/src/lib/server/runtime.server.ts +172 -0
  209. package/src/lib/utils.ts +9 -0
  210. package/src/logo.svg +1 -0
  211. package/src/routeTree.gen.ts +199 -0
  212. package/src/router.tsx +21 -0
  213. package/src/routes/-installations-search.ts +19 -0
  214. package/src/routes/-releases-search.ts +74 -0
  215. package/src/routes/__root.tsx +174 -0
  216. package/src/routes/analytics.tsx +139 -0
  217. package/src/routes/api/auth/$.ts +19 -0
  218. package/src/routes/api/bundles/$bundleId/download.ts +17 -0
  219. package/src/routes/api-keys.tsx +37 -0
  220. package/src/routes/index.tsx +982 -0
  221. package/src/routes/installations.tsx +194 -0
  222. package/src/routes/signing.tsx +37 -0
  223. package/src/styles.css +152 -0
  224. package/src/virtual-modules.d.ts +21 -0
  225. package/src/vite-env.d.ts +1 -0
  226. package/src/vite-internal.ts +195 -0
  227. package/src/vite.ts +12 -0
  228. package/.output/public/assets/dist-DJX53faP.js +0 -3
  229. package/.output/public/assets/index-Iylis5kY.js +0 -10
  230. package/.output/public/assets/routes-HT21hs0C.js +0 -54
  231. package/.output/public/assets/styles-C0Re4quB.css +0 -2
  232. package/.output/server/__23tanstack-start-server-fn-resolver-CgwgZ0-i.mjs +0 -66
  233. package/.output/server/_libs/@floating-ui/core+[...].mjs +0 -698
  234. package/.output/server/_libs/@floating-ui/dom+[...].mjs +0 -644
  235. package/.output/server/_libs/@floating-ui/react-dom+[...].mjs +0 -839
  236. package/.output/server/_libs/@radix-ui/react-alert-dialog+[...].mjs +0 -2138
  237. package/.output/server/_libs/@radix-ui/react-popper+[...].mjs +0 -308
  238. package/.output/server/_libs/@radix-ui/react-select+[...].mjs +0 -1038
  239. package/.output/server/_libs/@tanstack/devtools-event-client+[...].mjs +0 -196
  240. package/.output/server/_libs/@tanstack/form-core+[...].mjs +0 -3228
  241. package/.output/server/_libs/@tanstack/react-form+[...].mjs +0 -430
  242. package/.output/server/_libs/@tanstack/react-table+[...].mjs +0 -2382
  243. package/.output/server/_libs/radix-ui__number.mjs +0 -6
  244. package/.output/server/_libs/radix-ui__primitive.mjs +0 -9
  245. package/.output/server/_libs/radix-ui__react-arrow.mjs +0 -23
  246. package/.output/server/_libs/radix-ui__react-collection.mjs +0 -78
  247. package/.output/server/_libs/radix-ui__react-direction.mjs +0 -13
  248. package/.output/server/_libs/radix-ui__react-label.mjs +0 -22
  249. package/.output/server/_libs/radix-ui__react-separator.mjs +0 -30
  250. package/.output/server/_libs/radix-ui__react-slider.mjs +0 -510
  251. package/.output/server/_libs/radix-ui__react-switch.mjs +0 -157
  252. package/.output/server/_libs/radix-ui__react-tooltip.mjs +0 -493
  253. package/.output/server/_ssr/api-rpc-BIbDUKSz.mjs +0 -292
  254. package/.output/server/_ssr/config.server-BSS366KT.mjs +0 -41
  255. package/.output/server/_ssr/deleteBundle-BgbZTtpW.mjs +0 -141
  256. package/.output/server/_ssr/dist-KboydRVk.mjs +0 -120
  257. package/.output/server/_ssr/router-C3hqEy3k.mjs +0 -307
  258. package/.output/server/_ssr/routes-B0lQvvlC.mjs +0 -3048
  259. package/.output/server/_ssr/start-02sLteYG.mjs +0 -4
  260. package/.output/server/_ssr/storageProfile-FVkjPoGb.mjs +0 -9
  261. package/.output/server/_tanstack-start-manifest_v-Bpx52WOU.mjs +0 -20
  262. package/.output/server/node_modules/tslib/modules/index.js +0 -70
  263. package/.output/server/node_modules/tslib/modules/package.json +0 -3
  264. package/.output/server/node_modules/tslib/package.json +0 -47
  265. package/.output/server/node_modules/tslib/tslib.js +0 -484
  266. package/.output/server/package.json +0 -9
@@ -0,0 +1,982 @@
1
+ import type { Bundle, ChannelRow, ReleaseRow } from "@hot-updater/plugin-core";
2
+ import { createFileRoute, Link } from "@tanstack/react-router";
3
+ import {
4
+ AlertTriangle,
5
+ ChevronDown,
6
+ ChevronLeft,
7
+ ChevronRight,
8
+ CircleOff,
9
+ Fingerprint,
10
+ Filter,
11
+ Package,
12
+ RotateCcw,
13
+ Tags,
14
+ X,
15
+ } from "lucide-react";
16
+ import { Fragment, useEffect, useState } from "react";
17
+
18
+ import { BundleIdDisplay } from "@/components/BundleIdDisplay";
19
+ import { ChannelBadge } from "@/components/ChannelBadge";
20
+ import { EnabledStatusIcon } from "@/components/EnabledStatusIcon";
21
+ import { BundleChildrenPanel } from "@/components/features/bundles/BundleChildrenPanel";
22
+ import { ChannelManagementDialog } from "@/components/features/channels/ChannelManagementDialog";
23
+ import { ReleaseEditorSheet } from "@/components/features/releases/ReleaseEditorSheet";
24
+ import { ReleaseStateBadge } from "@/components/features/releases/ReleaseStateBadge";
25
+ import { HashValueDisplay } from "@/components/HashValueDisplay";
26
+ import { PlatformIcon } from "@/components/PlatformIcon";
27
+ import { RolloutPercentageBadge } from "@/components/RolloutPercentageBadge";
28
+ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
29
+ import { Badge } from "@/components/ui/badge";
30
+ import { Button, buttonVariants } from "@/components/ui/button";
31
+ import { Input } from "@/components/ui/input";
32
+ import {
33
+ Select,
34
+ SelectContent,
35
+ SelectGroup,
36
+ SelectItem,
37
+ SelectTrigger,
38
+ SelectValue,
39
+ } from "@/components/ui/select";
40
+ import { SidebarTrigger } from "@/components/ui/sidebar";
41
+ import { Skeleton } from "@/components/ui/skeleton";
42
+ import {
43
+ Table,
44
+ TableBody,
45
+ TableCell,
46
+ TableHead,
47
+ TableHeader,
48
+ TableRow,
49
+ } from "@/components/ui/table";
50
+ import { useIsMobile } from "@/hooks/use-mobile";
51
+ import {
52
+ useBundleChildCountsQuery,
53
+ useBundleChildrenQuery,
54
+ useBundleQuery,
55
+ useChannelsQuery,
56
+ useReleasesQuery,
57
+ } from "@/lib/api";
58
+ import type { ReleaseListRow } from "@/lib/server/releaseReachability";
59
+ import { cn } from "@/lib/utils";
60
+
61
+ import {
62
+ type ReleaseSearch,
63
+ updateReleaseFilters,
64
+ validateReleaseSearch,
65
+ } from "./-releases-search";
66
+
67
+ const PAGE_SIZE = 20;
68
+ const platformFilterItems = [
69
+ { label: "All Platforms", value: "all" },
70
+ { label: "iOS", value: "ios" },
71
+ { label: "Android", value: "android" },
72
+ ];
73
+ const dateFormatter = new Intl.DateTimeFormat(undefined, {
74
+ dateStyle: "medium",
75
+ timeStyle: "short",
76
+ });
77
+ const tableDateFormatter = new Intl.DateTimeFormat(undefined, {
78
+ dateStyle: "medium",
79
+ });
80
+
81
+ export const Route = createFileRoute("/")({
82
+ component: BundlesPage,
83
+ validateSearch: validateReleaseSearch,
84
+ });
85
+
86
+ const shortId = (id: string) =>
87
+ id.length > 18 ? `${id.slice(0, 8)}…${id.slice(-4)}` : id;
88
+
89
+ function BundleMovementSummary({
90
+ summary,
91
+ }: {
92
+ readonly summary: ReleaseListRow["activity30d"];
93
+ }) {
94
+ if (summary === null) {
95
+ return (
96
+ <span
97
+ aria-label="30-day Bundle activity unavailable"
98
+ className="text-sm text-muted-foreground"
99
+ title="Analytics are unavailable for this Bundle."
100
+ >
101
+
102
+ </span>
103
+ );
104
+ }
105
+
106
+ return (
107
+ <div
108
+ aria-label="Bundle movement over 30 days, distinct installations"
109
+ className="flex min-w-[140px] items-baseline gap-3 whitespace-nowrap"
110
+ role="group"
111
+ title="Distinct installations over the last 30 days."
112
+ >
113
+ <span className="flex items-baseline gap-1.5">
114
+ <span className="text-xs text-muted-foreground">Applied</span>
115
+ <span className="text-sm font-medium tabular-nums">
116
+ {summary.installed}
117
+ </span>
118
+ </span>
119
+ <span className="flex items-baseline gap-1.5">
120
+ <span className="text-xs text-muted-foreground">Recovered</span>
121
+ <span className="text-sm font-medium tabular-nums">
122
+ {summary.recovered}
123
+ </span>
124
+ </span>
125
+ </div>
126
+ );
127
+ }
128
+
129
+ function BundleFilterToolbar({
130
+ channels,
131
+ onChange,
132
+ onClear,
133
+ onManageChannels,
134
+ search,
135
+ }: {
136
+ channels: readonly ChannelRow[];
137
+ onChange: (filters: Partial<ReleaseSearch>) => void;
138
+ onClear: () => void;
139
+ onManageChannels: () => void;
140
+ search: ReleaseSearch;
141
+ }) {
142
+ const [targetAppVersion, setTargetAppVersion] = useState(
143
+ search.targetAppVersion ?? "",
144
+ );
145
+ const hasFilters = Boolean(
146
+ search.bundleId ||
147
+ search.channelId ||
148
+ search.enabled !== undefined ||
149
+ search.platform ||
150
+ search.targetAppVersion,
151
+ );
152
+ const channelFilterItems = [
153
+ { label: "All Channels", value: "all" },
154
+ ...channels.map((channel) => ({
155
+ label: channel.name,
156
+ value: channel.id,
157
+ })),
158
+ ];
159
+ const applyTargetAppVersion = () => {
160
+ const value = targetAppVersion.trim();
161
+ onChange({ targetAppVersion: value || undefined });
162
+ };
163
+
164
+ useEffect(() => {
165
+ setTargetAppVersion(search.targetAppVersion ?? "");
166
+ }, [search.targetAppVersion]);
167
+
168
+ return (
169
+ <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">
170
+ <SidebarTrigger className="-ml-1" />
171
+ <h1 className="sr-only">Bundles</h1>
172
+ <div className="ml-1 flex items-center gap-1.5 text-muted-foreground sm:ml-2">
173
+ <Filter className="size-3.5" />
174
+ <span className="text-xs font-medium">Filters</span>
175
+ </div>
176
+ <Select
177
+ items={platformFilterItems}
178
+ onValueChange={(value) =>
179
+ onChange({
180
+ platform:
181
+ value === "all" ? undefined : (value as "ios" | "android"),
182
+ })
183
+ }
184
+ value={search.platform ?? "all"}
185
+ >
186
+ <SelectTrigger
187
+ aria-label="Platform"
188
+ className="h-8 w-[calc(50%-0.25rem)] min-w-[132px] text-xs sm:w-[140px]"
189
+ >
190
+ <SelectValue />
191
+ </SelectTrigger>
192
+ <SelectContent>
193
+ <SelectGroup>
194
+ <SelectItem value="all">All Platforms</SelectItem>
195
+ <SelectItem value="ios">iOS</SelectItem>
196
+ <SelectItem value="android">Android</SelectItem>
197
+ </SelectGroup>
198
+ </SelectContent>
199
+ </Select>
200
+ <Input
201
+ aria-label="Target app version"
202
+ className="h-8 w-full min-w-[132px] text-xs sm:w-[160px]"
203
+ onBlur={applyTargetAppVersion}
204
+ onChange={(event) => setTargetAppVersion(event.target.value)}
205
+ onKeyDown={(event) => {
206
+ if (event.key === "Enter") {
207
+ applyTargetAppVersion();
208
+ }
209
+ }}
210
+ placeholder="Target version"
211
+ value={targetAppVersion}
212
+ />
213
+ <Select
214
+ items={channelFilterItems}
215
+ onValueChange={(value) =>
216
+ onChange({
217
+ channelId: value === null || value === "all" ? undefined : value,
218
+ })
219
+ }
220
+ value={search.channelId ?? "all"}
221
+ >
222
+ <SelectTrigger
223
+ aria-label="Channel"
224
+ className="h-8 w-[calc(50%-0.25rem)] min-w-[132px] text-xs sm:w-[140px]"
225
+ >
226
+ <SelectValue />
227
+ </SelectTrigger>
228
+ <SelectContent>
229
+ <SelectGroup>
230
+ <SelectItem value="all">All Channels</SelectItem>
231
+ {channels.map((channel) => (
232
+ <SelectItem key={channel.id} value={channel.id}>
233
+ {channel.name}
234
+ </SelectItem>
235
+ ))}
236
+ </SelectGroup>
237
+ </SelectContent>
238
+ </Select>
239
+ <Button
240
+ aria-label="Manage channels"
241
+ onClick={onManageChannels}
242
+ size="icon-sm"
243
+ title="Manage channels"
244
+ variant="outline"
245
+ >
246
+ <Tags />
247
+ </Button>
248
+ {search.bundleId ? (
249
+ <Badge className="max-w-48 gap-1" variant="secondary">
250
+ Bundle {shortId(search.bundleId)}
251
+ <button
252
+ aria-label="Clear Bundle filter"
253
+ className="rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
254
+ onClick={() => onChange({ bundleId: undefined })}
255
+ type="button"
256
+ >
257
+ <X className="size-3" />
258
+ </button>
259
+ </Badge>
260
+ ) : null}
261
+ {hasFilters ? (
262
+ <Button
263
+ className="h-8 px-2 text-xs text-muted-foreground hover:text-foreground sm:ml-auto"
264
+ onClick={onClear}
265
+ size="sm"
266
+ variant="ghost"
267
+ >
268
+ <X data-icon="inline-start" />
269
+ Clear
270
+ </Button>
271
+ ) : null}
272
+ </header>
273
+ );
274
+ }
275
+
276
+ function BundleEntry({
277
+ expanded,
278
+ onOpen,
279
+ onToggleExpand,
280
+ release,
281
+ }: {
282
+ expanded: boolean;
283
+ onOpen: () => void;
284
+ onToggleExpand: () => void;
285
+ release: ReleaseListRow;
286
+ }) {
287
+ const bundleLabel = release.bundle_id
288
+ ? `bundle ${release.bundle_id}`
289
+ : "the built-in app deployment";
290
+ const operationLabel =
291
+ release.operation === "PROMOTE"
292
+ ? "Promoted"
293
+ : release.operation === "ROLLBACK"
294
+ ? "Rollback"
295
+ : null;
296
+
297
+ return (
298
+ <div className="flex min-w-[240px] items-center gap-3">
299
+ {release.bundle_id ? (
300
+ <Button
301
+ aria-controls={`bundle-lineage-panel-${release.id}`}
302
+ aria-expanded={expanded}
303
+ aria-label={expanded ? "Hide Lineage" : "Show Lineage"}
304
+ className="size-8 shrink-0 touch-manipulation"
305
+ onClick={onToggleExpand}
306
+ size="icon"
307
+ type="button"
308
+ variant="ghost"
309
+ >
310
+ {expanded ? <ChevronDown /> : <ChevronRight />}
311
+ </Button>
312
+ ) : (
313
+ <span aria-hidden="true" className="size-8 shrink-0" />
314
+ )}
315
+ <button
316
+ aria-label={`Open details for ${bundleLabel}`}
317
+ className="min-w-0 rounded-sm text-left text-foreground transition-colors underline-offset-4 hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
318
+ onClick={onOpen}
319
+ title={release.bundle_id ?? release.id}
320
+ type="button"
321
+ >
322
+ {release.bundle_id ? (
323
+ <BundleIdDisplay bundleId={release.bundle_id} fullOnMobile />
324
+ ) : (
325
+ <span className="inline-flex items-center gap-1.5 text-sm">
326
+ <RotateCcw className="size-3.5" /> Built-in app
327
+ </span>
328
+ )}
329
+ </button>
330
+ {operationLabel ? (
331
+ <Badge className="shrink-0 font-normal" variant="secondary">
332
+ {operationLabel}
333
+ </Badge>
334
+ ) : null}
335
+ {release.currentlyUnreachable ? (
336
+ <span
337
+ aria-label="Currently unreachable. No catalog segment or cohort selects this release first."
338
+ className="inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground"
339
+ title="No catalog segment or cohort selects this release first with the current delivery settings."
340
+ >
341
+ <CircleOff className="size-3.5" />
342
+ Unreachable
343
+ </span>
344
+ ) : null}
345
+ </div>
346
+ );
347
+ }
348
+
349
+ function ReleaseBundleChildrenPanel({
350
+ onDetailClick,
351
+ panelId,
352
+ release,
353
+ }: {
354
+ onDetailClick: (bundle: Bundle) => void;
355
+ panelId: string;
356
+ release: ReleaseRow;
357
+ }) {
358
+ const bundleId = release.bundle_id ?? "";
359
+ const bundleQuery = useBundleQuery(bundleId);
360
+ const childrenQuery = useBundleChildrenQuery(bundleId);
361
+
362
+ if (bundleQuery.isError || childrenQuery.isError) {
363
+ return (
364
+ <div
365
+ aria-live="polite"
366
+ className="border-t bg-muted/10 p-4 text-sm text-muted-foreground"
367
+ id={panelId}
368
+ >
369
+ Patch lineage could not be loaded.
370
+ </div>
371
+ );
372
+ }
373
+
374
+ if (bundleQuery.isPending) {
375
+ return (
376
+ <div
377
+ aria-live="polite"
378
+ className="flex flex-col gap-2 border-t bg-muted/10 p-4"
379
+ id={panelId}
380
+ >
381
+ <Skeleton className="h-10 w-full" />
382
+ <Skeleton className="h-10 w-full" />
383
+ </div>
384
+ );
385
+ }
386
+
387
+ if (!bundleQuery.data) {
388
+ return (
389
+ <div
390
+ aria-live="polite"
391
+ className="border-t bg-muted/10 p-4 text-sm text-muted-foreground"
392
+ id={panelId}
393
+ >
394
+ Bundle details are unavailable.
395
+ </div>
396
+ );
397
+ }
398
+
399
+ return (
400
+ <BundleChildrenPanel
401
+ bundle={bundleQuery.data}
402
+ bundles={childrenQuery.data ?? []}
403
+ loading={childrenQuery.isPending}
404
+ onDetailClick={onDetailClick}
405
+ panelId={panelId}
406
+ />
407
+ );
408
+ }
409
+
410
+ function BundlesPage() {
411
+ const search = Route.useSearch();
412
+ const navigate = Route.useNavigate();
413
+ const isMobile = useIsMobile();
414
+ const [channelsOpen, setChannelsOpen] = useState(false);
415
+ const [expandedReleaseId, setExpandedReleaseId] = useState<string>();
416
+ const releasesQuery = useReleasesQuery({
417
+ afterReleaseId: search.afterReleaseId,
418
+ beforeReleaseId: search.beforeReleaseId,
419
+ bundleId: search.bundleId,
420
+ channelId: search.channelId,
421
+ enabled: search.enabled,
422
+ limit: PAGE_SIZE,
423
+ page: search.page,
424
+ platform: search.platform,
425
+ targetAppVersion: search.targetAppVersion,
426
+ });
427
+ const channelsQuery = useChannelsQuery();
428
+ const channels = channelsQuery.data ?? [];
429
+ const releases = releasesQuery.data?.data ?? [];
430
+ const bundleIds = Array.from(
431
+ new Set(
432
+ releases.flatMap((release) =>
433
+ release.bundle_id ? [release.bundle_id] : [],
434
+ ),
435
+ ),
436
+ );
437
+ const patchCountsQuery = useBundleChildCountsQuery(bundleIds);
438
+ const patchCountsByBundleId = patchCountsQuery.data ?? {};
439
+ const pagination = releasesQuery.data?.pagination;
440
+ const channelNames = new Map(
441
+ channels.map((channel) => [channel.id, channel.name]),
442
+ );
443
+
444
+ useEffect(() => {
445
+ if (
446
+ expandedReleaseId &&
447
+ !releases.some((release) => release.id === expandedReleaseId)
448
+ ) {
449
+ setExpandedReleaseId(undefined);
450
+ }
451
+ }, [expandedReleaseId, releases]);
452
+
453
+ const go = (nextSearch: ReleaseSearch, resetScroll = true) =>
454
+ void navigate({ resetScroll, search: nextSearch, to: "/" });
455
+ const changeFilters = (filters: Partial<ReleaseSearch>) =>
456
+ go(updateReleaseFilters(search, filters));
457
+ const openChildBundle = (bundle: Bundle) => {
458
+ const childRelease = releases.find(
459
+ (release) => release.bundle_id === bundle.id,
460
+ );
461
+ setExpandedReleaseId(undefined);
462
+ if (childRelease) {
463
+ go({ ...search, releaseId: childRelease.id }, false);
464
+ return;
465
+ }
466
+ changeFilters({ bundleId: bundle.id });
467
+ };
468
+ const currentPage = pagination?.currentPage ?? 1;
469
+ const startEntry =
470
+ releases.length === 0 ? 0 : (currentPage - 1) * PAGE_SIZE + 1;
471
+ const endEntry = startEntry === 0 ? 0 : startEntry + releases.length - 1;
472
+ const previousPage = currentPage - 1;
473
+ const firstReleaseId = releases[0]?.id;
474
+ const lastReleaseId = releases.at(-1)?.id;
475
+ const previousSearch: ReleaseSearch | null =
476
+ pagination?.hasPreviousPage && firstReleaseId
477
+ ? {
478
+ ...search,
479
+ afterReleaseId: firstReleaseId,
480
+ beforeReleaseId: undefined,
481
+ page: previousPage > 1 ? previousPage : undefined,
482
+ releaseId: undefined,
483
+ }
484
+ : null;
485
+ const nextSearch: ReleaseSearch | null =
486
+ pagination?.hasNextPage && lastReleaseId
487
+ ? {
488
+ ...search,
489
+ afterReleaseId: undefined,
490
+ beforeReleaseId: lastReleaseId,
491
+ page: currentPage + 1,
492
+ releaseId: undefined,
493
+ }
494
+ : null;
495
+
496
+ return (
497
+ <div className="flex h-svh min-h-0 min-w-0 flex-col">
498
+ <BundleFilterToolbar
499
+ channels={channels}
500
+ onChange={changeFilters}
501
+ onClear={() => go({})}
502
+ onManageChannels={() => setChannelsOpen(true)}
503
+ search={search}
504
+ />
505
+
506
+ <div className="flex min-h-0 min-w-0 flex-1 flex-col gap-6 bg-muted/5 p-3 sm:p-6">
507
+ {releasesQuery.isError ? (
508
+ <Alert variant="destructive">
509
+ <AlertTriangle />
510
+ <AlertTitle>Bundles could not be loaded</AlertTitle>
511
+ <AlertDescription>{releasesQuery.error.message}</AlertDescription>
512
+ </Alert>
513
+ ) : (
514
+ <div className="min-h-0 min-w-0 flex-1 overflow-hidden rounded-lg border bg-card text-card-foreground shadow-sm [&_[data-slot=table-container]]:h-full [&_[data-slot=table-container]]:overflow-auto">
515
+ {isMobile ? (
516
+ <div className="h-full overflow-y-auto">
517
+ {releasesQuery.isPending ? (
518
+ <div className="flex flex-col gap-3 p-4">
519
+ {Array.from({ length: 5 }, (_, index) => (
520
+ <Skeleton className="h-44 w-full" key={index} />
521
+ ))}
522
+ </div>
523
+ ) : releases.length ? (
524
+ releases.map((release) => {
525
+ const channelName =
526
+ channelNames.get(release.channel_id) ??
527
+ release.channel_id;
528
+ const patchCount = release.bundle_id
529
+ ? patchCountsByBundleId[release.bundle_id]
530
+ : 0;
531
+ const isExpanded = expandedReleaseId === release.id;
532
+ const panelId = `bundle-lineage-panel-${release.id}`;
533
+
534
+ return (
535
+ <Fragment key={release.id}>
536
+ <article
537
+ className={cn(
538
+ "flex flex-col gap-4 border-b p-4 last:border-b-0",
539
+ release.currentlyUnreachable &&
540
+ "bg-muted/35 saturate-50 transition-[background-color,filter] focus-within:saturate-100 motion-reduce:transition-none",
541
+ isExpanded && "border-b-0 bg-primary/5",
542
+ )}
543
+ >
544
+ <div className="flex items-start justify-between gap-3">
545
+ <BundleEntry
546
+ expanded={isExpanded}
547
+ onOpen={() =>
548
+ go({ ...search, releaseId: release.id }, false)
549
+ }
550
+ onToggleExpand={() =>
551
+ setExpandedReleaseId(
552
+ isExpanded ? undefined : release.id,
553
+ )
554
+ }
555
+ release={release}
556
+ />
557
+ <ChannelBadge
558
+ channel={channelName}
559
+ className="shrink-0"
560
+ />
561
+ </div>
562
+ <dl className="grid grid-cols-2 gap-3 text-xs">
563
+ <div className="rounded-md bg-muted/40 p-3">
564
+ <dt className="text-muted-foreground">
565
+ Platform
566
+ </dt>
567
+ <dd className="mt-1 flex items-center gap-2 font-medium">
568
+ <PlatformIcon
569
+ className="size-4"
570
+ platform={release.platform}
571
+ />
572
+ {release.platform === "ios" ? "iOS" : "Android"}
573
+ </dd>
574
+ </div>
575
+ <div className="rounded-md bg-muted/40 p-3">
576
+ <dt className="text-muted-foreground">Created</dt>
577
+ <dd className="mt-1 tabular-nums">
578
+ {dateFormatter.format(release.created_at_ms)}
579
+ </dd>
580
+ </div>
581
+ <div className="rounded-md bg-muted/40 p-3">
582
+ <dt className="text-muted-foreground">Rollout</dt>
583
+ <dd className="mt-1">
584
+ <RolloutPercentageBadge
585
+ percentage={release.rollout_cohort_count / 10}
586
+ />
587
+ </dd>
588
+ </div>
589
+ <div className="rounded-md bg-muted/40 p-3">
590
+ <dt className="text-muted-foreground">Patches</dt>
591
+ <dd className="mt-1">
592
+ {patchCount === undefined
593
+ ? "Checking"
594
+ : patchCount > 0
595
+ ? `${patchCount} ${
596
+ patchCount === 1 ? "patch" : "patches"
597
+ }`
598
+ : "—"}
599
+ </dd>
600
+ </div>
601
+ <div className="col-span-2 rounded-md bg-muted/40 p-3">
602
+ <dt className="text-muted-foreground">
603
+ Activity · 30d
604
+ </dt>
605
+ <dd className="mt-1">
606
+ <BundleMovementSummary
607
+ summary={release.activity30d}
608
+ />
609
+ </dd>
610
+ </div>
611
+ <div className="col-span-2 flex flex-wrap items-center gap-2 rounded-md bg-muted/40 p-3">
612
+ <ReleaseStateBadge release={release} />
613
+ {release.should_force_update ? (
614
+ <Badge variant="secondary">Force update</Badge>
615
+ ) : null}
616
+ </div>
617
+ <div className="col-span-2 grid gap-2 rounded-md border bg-background/80 p-3">
618
+ <div className="flex items-start justify-between gap-3">
619
+ <dt className="text-muted-foreground">
620
+ Target
621
+ </dt>
622
+ <dd className="min-w-0 truncate text-right font-mono">
623
+ {release.target_app_version ??
624
+ release.fingerprint_hash ??
625
+ "—"}
626
+ </dd>
627
+ </div>
628
+ <div className="flex items-start justify-between gap-3">
629
+ <dt className="text-muted-foreground">
630
+ Message
631
+ </dt>
632
+ <dd className="min-w-0 text-right text-foreground">
633
+ {release.message || "—"}
634
+ </dd>
635
+ </div>
636
+ </div>
637
+ </dl>
638
+ </article>
639
+ {isExpanded && release.bundle_id ? (
640
+ <ReleaseBundleChildrenPanel
641
+ onDetailClick={openChildBundle}
642
+ panelId={panelId}
643
+ release={release}
644
+ />
645
+ ) : null}
646
+ </Fragment>
647
+ );
648
+ })
649
+ ) : (
650
+ <p className="p-8 text-center text-sm text-muted-foreground">
651
+ {search.bundleId ||
652
+ search.channelId ||
653
+ search.enabled !== undefined ||
654
+ search.platform ||
655
+ search.targetAppVersion
656
+ ? "No bundles match these filters."
657
+ : "No bundles yet. Deploy a bundle to see it here."}
658
+ </p>
659
+ )}
660
+ </div>
661
+ ) : (
662
+ <Table className="min-w-max">
663
+ <TableHeader className="bg-muted/40">
664
+ <TableRow className="border-b border-border/60 hover:bg-transparent [&>th]:h-10 [&>th]:text-xs [&>th]:font-semibold [&>th]:uppercase [&>th]:text-muted-foreground/70">
665
+ <TableHead>Bundle ID</TableHead>
666
+ <TableHead>Channel</TableHead>
667
+ <TableHead>Platform</TableHead>
668
+ <TableHead>Patches</TableHead>
669
+ <TableHead>Target</TableHead>
670
+ <TableHead>Enabled</TableHead>
671
+ <TableHead>Force update</TableHead>
672
+ <TableHead>Rollout</TableHead>
673
+ <TableHead title="Distinct installations over the last 30 days.">
674
+ Activity · 30d
675
+ </TableHead>
676
+ <TableHead>Message</TableHead>
677
+ <TableHead>Created</TableHead>
678
+ </TableRow>
679
+ </TableHeader>
680
+ <TableBody>
681
+ {releasesQuery.isPending
682
+ ? Array.from({ length: 7 }, (_, index) => (
683
+ <TableRow key={index}>
684
+ <TableCell colSpan={11}>
685
+ <Skeleton className="h-8 w-full" />
686
+ </TableCell>
687
+ </TableRow>
688
+ ))
689
+ : releases.map((release) => {
690
+ const isExpanded = expandedReleaseId === release.id;
691
+ const panelId = `bundle-lineage-panel-${release.id}`;
692
+
693
+ return (
694
+ <Fragment key={release.id}>
695
+ <TableRow
696
+ aria-label={`Open bundle ${release.bundle_id ?? release.id}`}
697
+ className={cn(
698
+ "cursor-pointer transition-[background-color,filter] hover:bg-muted/10 focus-within:bg-muted/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring motion-reduce:transition-none data-[state=selected]:bg-muted/15 [&>td]:py-3",
699
+ release.currentlyUnreachable &&
700
+ "bg-muted/35 saturate-50 hover:bg-muted/50 hover:saturate-100 focus-within:saturate-100",
701
+ isExpanded && "bg-primary/5",
702
+ )}
703
+ data-state={
704
+ search.releaseId === release.id
705
+ ? "selected"
706
+ : undefined
707
+ }
708
+ onClick={(event) => {
709
+ if (
710
+ event.target instanceof Element &&
711
+ event.target.closest(
712
+ "button, a, input, select, textarea",
713
+ )
714
+ ) {
715
+ return;
716
+ }
717
+ go({ ...search, releaseId: release.id }, false);
718
+ }}
719
+ onKeyDown={(event) => {
720
+ if (
721
+ event.target !== event.currentTarget ||
722
+ (event.key !== "Enter" && event.key !== " ")
723
+ ) {
724
+ return;
725
+ }
726
+ event.preventDefault();
727
+ go({ ...search, releaseId: release.id }, false);
728
+ }}
729
+ tabIndex={0}
730
+ >
731
+ <TableCell>
732
+ <BundleEntry
733
+ expanded={isExpanded}
734
+ onOpen={() =>
735
+ go(
736
+ { ...search, releaseId: release.id },
737
+ false,
738
+ )
739
+ }
740
+ onToggleExpand={() =>
741
+ setExpandedReleaseId(
742
+ isExpanded ? undefined : release.id,
743
+ )
744
+ }
745
+ release={release}
746
+ />
747
+ </TableCell>
748
+ <TableCell>
749
+ <ChannelBadge
750
+ channel={
751
+ channelNames.get(release.channel_id) ??
752
+ release.channel_id
753
+ }
754
+ />
755
+ </TableCell>
756
+ <TableCell className="whitespace-nowrap">
757
+ <span className="flex items-center gap-2">
758
+ <PlatformIcon
759
+ className="size-4"
760
+ platform={release.platform}
761
+ />
762
+ {release.platform === "ios"
763
+ ? "iOS"
764
+ : "Android"}
765
+ </span>
766
+ </TableCell>
767
+ <TableCell>
768
+ {release.bundle_id &&
769
+ patchCountsByBundleId[release.bundle_id] ===
770
+ undefined ? (
771
+ <span className="text-sm text-muted-foreground">
772
+ Checking
773
+ </span>
774
+ ) : release.bundle_id &&
775
+ patchCountsByBundleId[release.bundle_id]! >
776
+ 0 ? (
777
+ <Badge variant="secondary">
778
+ {patchCountsByBundleId[release.bundle_id]}{" "}
779
+ {patchCountsByBundleId[
780
+ release.bundle_id
781
+ ] === 1
782
+ ? "patch"
783
+ : "patches"}
784
+ </Badge>
785
+ ) : (
786
+ <span className="text-muted-foreground">
787
+ -
788
+ </span>
789
+ )}
790
+ </TableCell>
791
+ <TableCell>
792
+ {release.target_app_version ? (
793
+ <span className="flex items-center gap-2">
794
+ <Package className="size-4 shrink-0 text-muted-foreground" />
795
+ <span className="font-medium">
796
+ {release.target_app_version}
797
+ </span>
798
+ </span>
799
+ ) : release.fingerprint_hash ? (
800
+ <span className="flex min-w-[160px] items-center gap-2">
801
+ <Fingerprint className="size-4 shrink-0 text-muted-foreground" />
802
+ <HashValueDisplay
803
+ maxLength={12}
804
+ value={release.fingerprint_hash}
805
+ />
806
+ </span>
807
+ ) : (
808
+ <span className="text-muted-foreground">
809
+ -
810
+ </span>
811
+ )}
812
+ </TableCell>
813
+ <TableCell>
814
+ <span className="flex items-center">
815
+ <EnabledStatusIcon
816
+ enabled={release.enabled}
817
+ />
818
+ <span className="sr-only">
819
+ {release.enabled ? "Enabled" : "Disabled"}
820
+ </span>
821
+ </span>
822
+ </TableCell>
823
+ <TableCell>
824
+ <span className="flex items-center">
825
+ <EnabledStatusIcon
826
+ enabled={release.should_force_update}
827
+ falseIcon="minus"
828
+ />
829
+ <span className="sr-only">
830
+ {release.should_force_update
831
+ ? "Required"
832
+ : "Optional"}
833
+ </span>
834
+ </span>
835
+ </TableCell>
836
+ <TableCell>
837
+ <RolloutPercentageBadge
838
+ percentage={release.rollout_cohort_count / 10}
839
+ />
840
+ </TableCell>
841
+ <TableCell>
842
+ <BundleMovementSummary
843
+ summary={release.activity30d}
844
+ />
845
+ </TableCell>
846
+ <TableCell
847
+ className="text-sm text-muted-foreground"
848
+ title={release.message ?? undefined}
849
+ >
850
+ {release.message || "-"}
851
+ </TableCell>
852
+ <TableCell
853
+ className="whitespace-nowrap text-xs text-muted-foreground"
854
+ title={dateFormatter.format(
855
+ release.created_at_ms,
856
+ )}
857
+ >
858
+ {tableDateFormatter.format(
859
+ release.created_at_ms,
860
+ )}
861
+ </TableCell>
862
+ </TableRow>
863
+ {isExpanded && release.bundle_id ? (
864
+ <TableRow className="hover:bg-transparent">
865
+ <TableCell
866
+ className="border-t-0 p-0"
867
+ colSpan={11}
868
+ >
869
+ <ReleaseBundleChildrenPanel
870
+ onDetailClick={openChildBundle}
871
+ panelId={panelId}
872
+ release={release}
873
+ />
874
+ </TableCell>
875
+ </TableRow>
876
+ ) : null}
877
+ </Fragment>
878
+ );
879
+ })}
880
+ {!releasesQuery.isPending && releases.length === 0 ? (
881
+ <TableRow>
882
+ <TableCell
883
+ className="h-40 text-center text-muted-foreground"
884
+ colSpan={10}
885
+ >
886
+ {search.bundleId ||
887
+ search.channelId ||
888
+ search.enabled !== undefined ||
889
+ search.platform ||
890
+ search.targetAppVersion
891
+ ? "No bundles match these filters."
892
+ : "No bundles yet. Deploy a bundle to see it here."}
893
+ </TableCell>
894
+ </TableRow>
895
+ ) : null}
896
+ </TableBody>
897
+ </Table>
898
+ )}
899
+ </div>
900
+ )}
901
+
902
+ {!releasesQuery.isError && !releasesQuery.isPending ? (
903
+ <nav
904
+ aria-label="Bundle pagination"
905
+ className="flex flex-col gap-3 px-2 sm:flex-row sm:items-center sm:justify-between"
906
+ >
907
+ <p className="text-xs font-medium text-muted-foreground">
908
+ Showing <span className="text-foreground">{startEntry}</span> to{" "}
909
+ <span className="text-foreground">{endEntry}</span> entries
910
+ </p>
911
+ <div className="flex flex-wrap items-center gap-3 sm:justify-end">
912
+ <p className="text-xs font-medium text-muted-foreground">
913
+ Page <span className="text-foreground">{currentPage}</span>
914
+ </p>
915
+ {previousSearch ? (
916
+ <Link
917
+ className={buttonVariants({
918
+ className: "h-8 flex-1 px-3 text-xs sm:flex-none",
919
+ size: "sm",
920
+ variant: "outline",
921
+ })}
922
+ search={previousSearch}
923
+ to="/"
924
+ >
925
+ <ChevronLeft data-icon="inline-start" />
926
+ Previous
927
+ </Link>
928
+ ) : (
929
+ <Button
930
+ className="h-8 flex-1 px-3 text-xs sm:flex-none"
931
+ disabled
932
+ size="sm"
933
+ variant="outline"
934
+ >
935
+ <ChevronLeft data-icon="inline-start" />
936
+ Previous
937
+ </Button>
938
+ )}
939
+ {nextSearch ? (
940
+ <Link
941
+ className={buttonVariants({
942
+ className: "h-8 flex-1 px-3 text-xs sm:flex-none",
943
+ size: "sm",
944
+ variant: "outline",
945
+ })}
946
+ search={nextSearch}
947
+ to="/"
948
+ >
949
+ Next
950
+ <ChevronRight data-icon="inline-end" />
951
+ </Link>
952
+ ) : (
953
+ <Button
954
+ className="h-8 flex-1 px-3 text-xs sm:flex-none"
955
+ disabled
956
+ size="sm"
957
+ variant="outline"
958
+ >
959
+ Next
960
+ <ChevronRight data-icon="inline-end" />
961
+ </Button>
962
+ )}
963
+ </div>
964
+ </nav>
965
+ ) : null}
966
+ </div>
967
+
968
+ <ReleaseEditorSheet
969
+ channels={channels}
970
+ onOpenChange={(open) =>
971
+ !open && go({ ...search, releaseId: undefined }, false)
972
+ }
973
+ open={Boolean(search.releaseId)}
974
+ releaseId={search.releaseId ?? ""}
975
+ />
976
+ <ChannelManagementDialog
977
+ onOpenChange={setChannelsOpen}
978
+ open={channelsOpen}
979
+ />
980
+ </div>
981
+ );
982
+ }