@hot-updater/console 0.36.7 → 1.0.0-rc.1

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 (262) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/BundleIdDisplay-D4ErdU5_.js +1 -0
  3. package/.output/public/assets/InsightsCapabilityContext-B42VqvP6.js +1 -0
  4. package/.output/public/assets/api-keys-api-DGVTTvTJ.js +1 -0
  5. package/.output/public/assets/api-keys-z16OZIP4.js +1 -0
  6. package/.output/public/assets/badge-Br_KDO0J.js +1 -0
  7. package/.output/public/assets/card-DzuEXvyJ.js +1 -0
  8. package/.output/public/assets/chart-no-axes-combined-FaYMGmQS.js +1 -0
  9. package/.output/public/assets/createServerFn-gM2-LFBy.js +3 -0
  10. package/.output/public/assets/dialog-BeAENgez.js +1 -0
  11. package/.output/public/assets/dist-Bssh_cv0.js +1 -0
  12. package/.output/public/assets/field-Buq148-A.js +1 -0
  13. package/.output/public/assets/index-uOpQAIC4.js +10 -0
  14. package/.output/public/assets/input-group-B3p5UpyW.js +1 -0
  15. package/.output/public/assets/insights-DJTr7Ozn.js +1 -0
  16. package/.output/public/assets/insights-api-DVMOoRMS.js +1 -0
  17. package/.output/public/assets/installations-BY64iIAK.js +2 -0
  18. package/.output/public/assets/installations-D5v9i3AL.js +1 -0
  19. package/.output/public/assets/lazyRouteComponent-DKYu5cZJ.js +1 -0
  20. package/.output/public/assets/preload-helper-CQ36nxok.js +1 -0
  21. package/.output/public/assets/refresh-cw-BkbfZ62O.js +1 -0
  22. package/.output/public/assets/routes-BuoI5-Cr.js +2 -0
  23. package/.output/public/assets/routes-C8fvgETO.js +1 -0
  24. package/.output/public/assets/search-qsIDvS2I.js +1 -0
  25. package/.output/public/assets/shield-check-DE_hVuHu.js +1 -0
  26. package/.output/public/assets/signing-mJD88Y4C.js +1 -0
  27. package/.output/public/assets/styles-BZIG7t42.css +2 -0
  28. package/.output/public/assets/table-RamFEclP.js +1 -0
  29. package/.output/public/assets/useCompositeListItem-ChzDqdpc.js +42 -0
  30. package/.output/public/assets/useMutation-JsGVjQMY.js +1 -0
  31. package/.output/public/assets/useOpenInteractionType-Dwfy-SpA.js +1 -0
  32. package/.output/server/__23tanstack-start-server-fn-resolver-Dftxlm2E.mjs +146 -0
  33. package/.output/server/_libs/@base-ui/react+[...].mjs +16124 -0
  34. package/.output/server/_libs/@reduxjs/toolkit+[...].mjs +1554 -0
  35. package/.output/server/_libs/@tanstack/react-router+[...].mjs +41 -10
  36. package/.output/server/_libs/@tanstack/react-router-ssr-query+[...].mjs +132 -0
  37. package/.output/server/_libs/d3-array.mjs +231 -0
  38. package/.output/server/_libs/d3-color.mjs +330 -0
  39. package/.output/server/_libs/d3-format.mjs +252 -0
  40. package/.output/server/_libs/d3-interpolate.mjs +207 -0
  41. package/.output/server/_libs/d3-path.mjs +77 -0
  42. package/.output/server/_libs/d3-scale+[...].mjs +1907 -0
  43. package/.output/server/_libs/d3-shape.mjs +818 -0
  44. package/.output/server/_libs/decimal.js-light.mjs +884 -0
  45. package/.output/server/_libs/es-toolkit.mjs +1134 -0
  46. package/.output/server/_libs/eventemitter3.mjs +236 -0
  47. package/.output/server/_libs/immer.mjs +532 -0
  48. package/.output/server/_libs/lucide-react.mjs +141 -13
  49. package/.output/server/_libs/next-themes.mjs +2 -2
  50. package/.output/server/_libs/react-redux+[...].mjs +205 -0
  51. package/.output/server/_libs/recharts+[...].mjs +14117 -0
  52. package/.output/server/_libs/sonner.mjs +2 -2
  53. package/.output/server/_libs/tanstack__query-core.mjs +114 -1
  54. package/.output/server/_libs/tanstack__react-query.mjs +3 -4
  55. package/.output/server/_runtime.mjs +10 -1
  56. package/.output/server/_ssr/BundleActivityChart-CWcdPuUn.mjs +288 -0
  57. package/.output/server/_ssr/BundleIdDisplay-BEvB3MvA.mjs +13 -0
  58. package/.output/server/_ssr/InsightsCapabilityContext-Cx8fjm2s.mjs +15 -0
  59. package/.output/server/_ssr/api-keys-CpBqHpuC.mjs +300 -0
  60. package/.output/server/_ssr/api-keys-api-Czkp57UV.mjs +60 -0
  61. package/.output/server/_ssr/api-keys-rpc-BTtMDSoR.mjs +136 -0
  62. package/.output/server/_ssr/api-rpc-BEX8ISai.mjs +1468 -0
  63. package/.output/server/_ssr/auth-BF8mbAgF.mjs +21 -0
  64. package/.output/server/_ssr/auth-rpc-BFMEC-TT.mjs +24 -0
  65. package/.output/server/_ssr/auth.server-DW71WzsD.mjs +47 -0
  66. package/.output/server/_ssr/badge-CpWXQwyF.mjs +26 -0
  67. package/.output/server/_ssr/bundle-signing-rpc-BbCFUQSu.mjs +16 -0
  68. package/.output/server/_ssr/bundle-signing.server-C4NW5mWx.mjs +12 -0
  69. package/.output/server/_ssr/card-Bsc8mRis.mjs +86 -0
  70. package/.output/server/_ssr/config-Dv8qLDZ0.mjs +13 -0
  71. package/.output/server/_ssr/config.server-DQYEr_5X.mjs +361 -0
  72. package/.output/server/_ssr/{createServerFn-DzU0k62V.mjs → createServerFn-B2hcXoPM.mjs} +1 -1
  73. package/.output/server/_ssr/createServerRpc-DB29C_5p.mjs +12 -0
  74. package/.output/server/_ssr/{sidebar-Cv_H0_Jv.mjs → createSsrRpc-DhfrxEjg.mjs} +68 -50
  75. package/.output/server/_ssr/databaseRows-QlCYLi0H.mjs +166 -0
  76. package/.output/server/_ssr/deleteBundle-Cu_XOMt0.mjs +66 -0
  77. package/.output/server/_ssr/dialog-C0vKUp8R.mjs +170 -0
  78. package/.output/server/_ssr/dist-BVSLTpoP.mjs +250 -0
  79. package/.output/server/_ssr/downloadBundle-CNnYxYO2.mjs +24 -0
  80. package/.output/server/_ssr/{empty-plugin-adapters-B4HCzgmM.mjs → empty-plugin-adapters-B5QvGpEl.mjs} +1 -1
  81. package/.output/server/_ssr/field-DASCh-5d.mjs +74 -0
  82. package/.output/server/_ssr/{getBundleChildren-CVtSiH5t.mjs → getBundleChildren-BcRMFoDF.mjs} +10 -18
  83. package/.output/server/_ssr/input-group-etIsBUqv.mjs +288 -0
  84. package/.output/server/_ssr/insights-api-C1F7UreR.mjs +71 -0
  85. package/.output/server/_ssr/insights-input-9fEJF-ZG.mjs +96 -0
  86. package/.output/server/_ssr/insights-rpc-C-8R6kED.mjs +155 -0
  87. package/.output/server/_ssr/insights-ygT_aIfo.mjs +858 -0
  88. package/.output/server/_ssr/installations-DqRT1QTd.mjs +535 -0
  89. package/.output/server/_ssr/installations-K8TCYTYh.mjs +19 -0
  90. package/.output/server/_ssr/router-BxBEvtF1.mjs +513 -0
  91. package/.output/server/_ssr/routes-BC7rn-tf.mjs +37 -0
  92. package/.output/server/_ssr/routes-CM5KAwpB.mjs +2285 -0
  93. package/.output/server/_ssr/runtime.server-la7TRAH_.mjs +527 -0
  94. package/.output/server/_ssr/server-CpzW--lT.mjs +7468 -0
  95. package/.output/server/_ssr/signing-jk4HANnl.mjs +134 -0
  96. package/.output/server/_ssr/ssr.mjs +4 -5740
  97. package/.output/server/_ssr/start-DpViQGnt.mjs +4 -0
  98. package/.output/server/_ssr/table-CU58HeI7.mjs +53 -0
  99. package/.output/server/_ssr/uuidv7-CaKuKXrV.mjs +31 -0
  100. package/.output/server/_tanstack-start-manifest_v-Mz9MmSlD.mjs +103 -0
  101. package/.output/server/index.mjs +229 -47
  102. package/README.md +29 -8
  103. package/lib/index.d.mts +35 -0
  104. package/lib/index.mjs +4 -0
  105. package/lib/vite-internal.mjs +127 -0
  106. package/lib/vite.d.mts +10 -0
  107. package/lib/vite.mjs +5 -0
  108. package/package.json +51 -29
  109. package/public/apple-touch-icon.png +0 -0
  110. package/public/favicon-16x16.png +0 -0
  111. package/public/favicon-32x32.png +0 -0
  112. package/public/favicon.ico +0 -0
  113. package/public/logo.svg +1 -0
  114. package/public/manifest.json +30 -0
  115. package/public/robots.txt +3 -0
  116. package/src/components/AppSidebar.tsx +159 -0
  117. package/src/components/BundleIdDisplay.tsx +18 -0
  118. package/src/components/ChannelBadge.tsx +31 -0
  119. package/src/components/ConsoleAccessPage.tsx +122 -0
  120. package/src/components/EnabledStatusIcon.tsx +54 -0
  121. package/src/components/HashValueDisplay.tsx +85 -0
  122. package/src/components/HotUpdaterLogo.tsx +30 -0
  123. package/src/components/NotFoundPage.tsx +33 -0
  124. package/src/components/PlatformIcon.tsx +39 -0
  125. package/src/components/RolloutPercentageBadge.tsx +28 -0
  126. package/src/components/ThemeProvider.tsx +116 -0
  127. package/src/components/TimestampDisplay.tsx +41 -0
  128. package/src/components/features/api-keys/ApiKeysPage.tsx +377 -0
  129. package/src/components/features/bundles/BundleActivityChart.tsx +193 -0
  130. package/src/components/features/bundles/BundleChildrenPanel.tsx +201 -0
  131. package/src/components/features/bundles/BundleInsightsSummary.tsx +123 -0
  132. package/src/components/features/bundles/BundleMetadata.tsx +136 -0
  133. package/src/components/features/bundles/RolloutCohortsDialog.tsx +201 -0
  134. package/src/components/features/channels/ChannelManagementDialog.tsx +228 -0
  135. package/src/components/features/insights/ActivityChart.tsx +150 -0
  136. package/src/components/features/insights/BundleDistribution.tsx +133 -0
  137. package/src/components/features/insights/BundleSelector.tsx +114 -0
  138. package/src/components/features/insights/InsightsCapabilityContext.tsx +24 -0
  139. package/src/components/features/insights/InsightsControls.tsx +115 -0
  140. package/src/components/features/insights/InsightsErrorAlert.tsx +21 -0
  141. package/src/components/features/insights/InsightsOverview.tsx +208 -0
  142. package/src/components/features/insights/InstallationHistoryCard.tsx +248 -0
  143. package/src/components/features/insights/InstallationMatchesCard.tsx +132 -0
  144. package/src/components/features/insights/InstallationPageHeader.tsx +138 -0
  145. package/src/components/features/insights/InstallationPagination.tsx +48 -0
  146. package/src/components/features/insights/InstallationSearch.tsx +230 -0
  147. package/src/components/features/insights/UpdateOutcomes.tsx +147 -0
  148. package/src/components/features/releases/ReleaseEditorSheet.tsx +879 -0
  149. package/src/components/features/releases/ReleaseStateBadge.tsx +20 -0
  150. package/src/components/features/signing/BundleSigningPage.tsx +139 -0
  151. package/src/components/ui/alert-dialog.tsx +185 -0
  152. package/src/components/ui/alert.tsx +76 -0
  153. package/src/components/ui/badge.tsx +49 -0
  154. package/src/components/ui/button.tsx +58 -0
  155. package/src/components/ui/card.tsx +89 -0
  156. package/src/components/ui/chart.tsx +371 -0
  157. package/src/components/ui/combobox.tsx +303 -0
  158. package/src/components/ui/dialog.tsx +156 -0
  159. package/src/components/ui/dropdown-menu.tsx +270 -0
  160. package/src/components/ui/field.tsx +235 -0
  161. package/src/components/ui/input-group.tsx +147 -0
  162. package/src/components/ui/input.tsx +19 -0
  163. package/src/components/ui/label.tsx +25 -0
  164. package/src/components/ui/progress.tsx +84 -0
  165. package/src/components/ui/select.tsx +205 -0
  166. package/src/components/ui/separator.tsx +23 -0
  167. package/src/components/ui/sheet.tsx +132 -0
  168. package/src/components/ui/sidebar.tsx +779 -0
  169. package/src/components/ui/skeleton.tsx +13 -0
  170. package/src/components/ui/slider.tsx +56 -0
  171. package/src/components/ui/sonner.tsx +45 -0
  172. package/src/components/ui/switch.tsx +25 -0
  173. package/src/components/ui/table.tsx +114 -0
  174. package/src/components/ui/textarea.tsx +18 -0
  175. package/src/components/ui/toggle-group.tsx +89 -0
  176. package/src/components/ui/toggle.tsx +43 -0
  177. package/src/components/ui/tooltip.tsx +66 -0
  178. package/src/hooks/use-mobile.ts +30 -0
  179. package/src/index.ts +47 -0
  180. package/src/lib/api-keys-api.ts +69 -0
  181. package/src/lib/api-keys-rpc.ts +87 -0
  182. package/src/lib/api-rpc.ts +478 -0
  183. package/src/lib/api.ts +444 -0
  184. package/src/lib/auth-rpc.ts +16 -0
  185. package/src/lib/bundle-signing-api.ts +14 -0
  186. package/src/lib/bundle-signing-rpc.ts +16 -0
  187. package/src/lib/constants.ts +1 -0
  188. package/src/lib/git.ts +51 -0
  189. package/src/lib/insights-api.ts +144 -0
  190. package/src/lib/insights-error.ts +19 -0
  191. package/src/lib/insights-input.ts +187 -0
  192. package/src/lib/insights-overview.ts +170 -0
  193. package/src/lib/insights-rpc.ts +218 -0
  194. package/src/lib/server/auth.server.ts +43 -0
  195. package/src/lib/server/bundle-signing.server.ts +23 -0
  196. package/src/lib/server/config.server.ts +109 -0
  197. package/src/lib/server/console-runtime.server.ts +61 -0
  198. package/src/lib/server/deleteBundle.ts +145 -0
  199. package/src/lib/server/downloadBundle.ts +46 -0
  200. package/src/lib/server/getBundleChildren.ts +130 -0
  201. package/src/lib/server/listReleases.ts +61 -0
  202. package/src/lib/server/releaseActivity.ts +27 -0
  203. package/src/lib/server/releaseReachability.ts +95 -0
  204. package/src/lib/server/runtime.server.ts +172 -0
  205. package/src/lib/utils.ts +9 -0
  206. package/src/logo.svg +1 -0
  207. package/src/routeTree.gen.ts +199 -0
  208. package/src/router.tsx +21 -0
  209. package/src/routes/-installations-search.ts +19 -0
  210. package/src/routes/-releases-search.ts +74 -0
  211. package/src/routes/__root.tsx +174 -0
  212. package/src/routes/api/auth/$.ts +19 -0
  213. package/src/routes/api/bundles/$bundleId/download.ts +17 -0
  214. package/src/routes/api-keys.tsx +37 -0
  215. package/src/routes/index.tsx +982 -0
  216. package/src/routes/insights.tsx +139 -0
  217. package/src/routes/installations.tsx +194 -0
  218. package/src/routes/signing.tsx +37 -0
  219. package/src/styles.css +152 -0
  220. package/src/virtual-modules.d.ts +21 -0
  221. package/src/vite-env.d.ts +1 -0
  222. package/src/vite-internal.ts +195 -0
  223. package/src/vite.ts +12 -0
  224. package/.output/public/assets/dist-DJX53faP.js +0 -3
  225. package/.output/public/assets/index-Iylis5kY.js +0 -10
  226. package/.output/public/assets/routes-HT21hs0C.js +0 -54
  227. package/.output/public/assets/styles-C0Re4quB.css +0 -2
  228. package/.output/server/__23tanstack-start-server-fn-resolver-CgwgZ0-i.mjs +0 -66
  229. package/.output/server/_libs/@floating-ui/core+[...].mjs +0 -698
  230. package/.output/server/_libs/@floating-ui/dom+[...].mjs +0 -644
  231. package/.output/server/_libs/@floating-ui/react-dom+[...].mjs +0 -839
  232. package/.output/server/_libs/@radix-ui/react-alert-dialog+[...].mjs +0 -2138
  233. package/.output/server/_libs/@radix-ui/react-popper+[...].mjs +0 -308
  234. package/.output/server/_libs/@radix-ui/react-select+[...].mjs +0 -1038
  235. package/.output/server/_libs/@tanstack/devtools-event-client+[...].mjs +0 -196
  236. package/.output/server/_libs/@tanstack/form-core+[...].mjs +0 -3228
  237. package/.output/server/_libs/@tanstack/react-form+[...].mjs +0 -430
  238. package/.output/server/_libs/@tanstack/react-table+[...].mjs +0 -2382
  239. package/.output/server/_libs/radix-ui__number.mjs +0 -6
  240. package/.output/server/_libs/radix-ui__primitive.mjs +0 -9
  241. package/.output/server/_libs/radix-ui__react-arrow.mjs +0 -23
  242. package/.output/server/_libs/radix-ui__react-collection.mjs +0 -78
  243. package/.output/server/_libs/radix-ui__react-direction.mjs +0 -13
  244. package/.output/server/_libs/radix-ui__react-label.mjs +0 -22
  245. package/.output/server/_libs/radix-ui__react-separator.mjs +0 -30
  246. package/.output/server/_libs/radix-ui__react-slider.mjs +0 -510
  247. package/.output/server/_libs/radix-ui__react-switch.mjs +0 -157
  248. package/.output/server/_libs/radix-ui__react-tooltip.mjs +0 -493
  249. package/.output/server/_ssr/api-rpc-BIbDUKSz.mjs +0 -292
  250. package/.output/server/_ssr/config.server-BSS366KT.mjs +0 -41
  251. package/.output/server/_ssr/deleteBundle-BgbZTtpW.mjs +0 -141
  252. package/.output/server/_ssr/dist-KboydRVk.mjs +0 -120
  253. package/.output/server/_ssr/router-C3hqEy3k.mjs +0 -307
  254. package/.output/server/_ssr/routes-B0lQvvlC.mjs +0 -3048
  255. package/.output/server/_ssr/start-02sLteYG.mjs +0 -4
  256. package/.output/server/_ssr/storageProfile-FVkjPoGb.mjs +0 -9
  257. package/.output/server/_tanstack-start-manifest_v-Bpx52WOU.mjs +0 -20
  258. package/.output/server/node_modules/tslib/modules/index.js +0 -70
  259. package/.output/server/node_modules/tslib/modules/package.json +0 -3
  260. package/.output/server/node_modules/tslib/package.json +0 -47
  261. package/.output/server/node_modules/tslib/tslib.js +0 -484
  262. package/.output/server/package.json +0 -9
@@ -0,0 +1,879 @@
1
+ import {
2
+ INVALID_COHORT_ERROR_MESSAGE,
3
+ isValidCohort,
4
+ normalizeCohortValue,
5
+ } from "@hot-updater/core";
6
+ import type {
7
+ ChannelRow,
8
+ ReleasePolicyPatch,
9
+ ReleaseRow,
10
+ } from "@hot-updater/plugin-core";
11
+ import { AlertTriangle, Download, Plus, X } from "lucide-react";
12
+ import { useEffect, useState } from "react";
13
+ import { toast } from "sonner";
14
+ import { normalizeRange } from "verkit";
15
+
16
+ import { BundleIdDisplay } from "@/components/BundleIdDisplay";
17
+ import { BundleInsightsSummary } from "@/components/features/bundles/BundleInsightsSummary";
18
+ import { BundleMetadata } from "@/components/features/bundles/BundleMetadata";
19
+ import { RolloutCohortsDialog } from "@/components/features/bundles/RolloutCohortsDialog";
20
+ import { PlatformIcon } from "@/components/PlatformIcon";
21
+ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
22
+ import {
23
+ AlertDialog,
24
+ AlertDialogAction,
25
+ AlertDialogCancel,
26
+ AlertDialogContent,
27
+ AlertDialogDescription,
28
+ AlertDialogFooter,
29
+ AlertDialogHeader,
30
+ AlertDialogTitle,
31
+ } from "@/components/ui/alert-dialog";
32
+ import { Badge } from "@/components/ui/badge";
33
+ import { Button } from "@/components/ui/button";
34
+ import {
35
+ Dialog,
36
+ DialogContent,
37
+ DialogDescription,
38
+ DialogFooter,
39
+ DialogHeader,
40
+ DialogTitle,
41
+ } from "@/components/ui/dialog";
42
+ import {
43
+ Field,
44
+ FieldDescription,
45
+ FieldError,
46
+ FieldGroup,
47
+ FieldLabel,
48
+ } from "@/components/ui/field";
49
+ import { Input } from "@/components/ui/input";
50
+ import {
51
+ InputGroup,
52
+ InputGroupAddon,
53
+ InputGroupInput,
54
+ InputGroupText,
55
+ } from "@/components/ui/input-group";
56
+ import { Label } from "@/components/ui/label";
57
+ import {
58
+ Select,
59
+ SelectContent,
60
+ SelectGroup,
61
+ SelectItem,
62
+ SelectTrigger,
63
+ SelectValue,
64
+ } from "@/components/ui/select";
65
+ import { Separator } from "@/components/ui/separator";
66
+ import {
67
+ Sheet,
68
+ SheetContent,
69
+ SheetDescription,
70
+ SheetHeader,
71
+ SheetTitle,
72
+ } from "@/components/ui/sheet";
73
+ import { Skeleton } from "@/components/ui/skeleton";
74
+ import { Slider } from "@/components/ui/slider";
75
+ import { Switch } from "@/components/ui/switch";
76
+ import { Textarea } from "@/components/ui/textarea";
77
+ import {
78
+ useBundleQuery,
79
+ useDeleteReleaseMutation,
80
+ usePreflightReleaseMutation,
81
+ usePromoteReleaseMutation,
82
+ useReleaseCatalogDiagnosticsQuery,
83
+ useReleaseQuery,
84
+ useUpdateReleaseMutation,
85
+ } from "@/lib/api";
86
+
87
+ interface Draft {
88
+ enabled: boolean;
89
+ message: string;
90
+ rolloutCohortCount: number;
91
+ shouldForceUpdate: boolean;
92
+ targetAppVersion: string;
93
+ targetCohorts: string[];
94
+ }
95
+
96
+ const promoteActionItems = [
97
+ { label: "Move bundle", value: "move" },
98
+ { label: "Copy bundle", value: "copy" },
99
+ ];
100
+
101
+ const draftFromRelease = (release: ReleaseRow): Draft => ({
102
+ enabled: release.enabled,
103
+ message: release.message ?? "",
104
+ rolloutCohortCount: release.rollout_cohort_count,
105
+ shouldForceUpdate: release.should_force_update,
106
+ targetAppVersion: release.target_app_version ?? "",
107
+ targetCohorts: [...release.target_cohorts],
108
+ });
109
+
110
+ const patchFromDraft = (
111
+ release: ReleaseRow,
112
+ draft: Draft,
113
+ ): ReleasePolicyPatch => ({
114
+ enabled: draft.enabled,
115
+ message: draft.message.trim() || null,
116
+ rolloutCohortCount: draft.rolloutCohortCount,
117
+ shouldForceUpdate: draft.shouldForceUpdate,
118
+ ...(release.strategy === "APP_VERSION"
119
+ ? { targetAppVersion: draft.targetAppVersion.trim() }
120
+ : {}),
121
+ targetCohorts: draft.targetCohorts,
122
+ });
123
+
124
+ const formatRolloutPercentage = (count: number) => (count / 10).toFixed(1);
125
+
126
+ const formatRolloutInput = (value: string) => {
127
+ const normalized = value.replace(",", ".");
128
+ const [integer = "", ...decimals] = normalized.split(".");
129
+ const integerDigits = integer.replace(/\D/g, "").slice(0, 3);
130
+ const decimalDigit = decimals.join("").replace(/\D/g, "").slice(0, 1);
131
+ return normalized.includes(".")
132
+ ? `${integerDigits}.${decimalDigit}`
133
+ : integerDigits;
134
+ };
135
+
136
+ const parseRolloutInput = (value: string) => {
137
+ const percentage = Number.parseFloat(value);
138
+ if (!Number.isFinite(percentage)) return null;
139
+ return Math.min(1_000, Math.max(0, Math.trunc(percentage * 10)));
140
+ };
141
+
142
+ function RolloutPercentageInput({
143
+ value,
144
+ onChange,
145
+ }: {
146
+ value: number;
147
+ onChange: (value: number) => void;
148
+ }) {
149
+ const [inputValue, setInputValue] = useState(formatRolloutPercentage(value));
150
+ const [focused, setFocused] = useState(false);
151
+
152
+ useEffect(() => {
153
+ if (!focused) setInputValue(formatRolloutPercentage(value));
154
+ }, [focused, value]);
155
+
156
+ const commitInput = () => {
157
+ const nextValue = parseRolloutInput(inputValue) ?? value;
158
+ setFocused(false);
159
+ setInputValue(formatRolloutPercentage(nextValue));
160
+ onChange(nextValue);
161
+ };
162
+
163
+ return (
164
+ <div className="flex items-center gap-3">
165
+ <Slider
166
+ aria-label="Rollout percentage"
167
+ max={1_000}
168
+ min={0}
169
+ onValueChange={(nextValue) =>
170
+ onChange(
171
+ Array.isArray(nextValue) ? (nextValue[0] ?? value) : nextValue,
172
+ )
173
+ }
174
+ step={1}
175
+ value={value}
176
+ />
177
+ <InputGroup className="w-24 shrink-0">
178
+ <InputGroupInput
179
+ aria-label="Rollout percentage"
180
+ autoComplete="off"
181
+ className="text-right tabular-nums"
182
+ id="release-rollout-percentage"
183
+ inputMode="decimal"
184
+ name="rolloutPercentage"
185
+ onBlur={commitInput}
186
+ onChange={(event) => {
187
+ const nextInput = formatRolloutInput(event.target.value);
188
+ setInputValue(nextInput);
189
+ const parsed = parseRolloutInput(nextInput);
190
+ if (parsed !== null) onChange(parsed);
191
+ }}
192
+ onFocus={() => setFocused(true)}
193
+ value={inputValue}
194
+ />
195
+ <InputGroupAddon align="inline-end">
196
+ <InputGroupText>%</InputGroupText>
197
+ </InputGroupAddon>
198
+ </InputGroup>
199
+ </div>
200
+ );
201
+ }
202
+
203
+ export function ReleaseEditorSheet({
204
+ channels,
205
+ onOpenChange,
206
+ open,
207
+ releaseId,
208
+ }: {
209
+ channels: readonly ChannelRow[];
210
+ onOpenChange: (open: boolean) => void;
211
+ open: boolean;
212
+ releaseId: string;
213
+ }) {
214
+ const releaseQuery = useReleaseQuery(releaseId);
215
+ const release = releaseQuery.data;
216
+ const bundleQuery = useBundleQuery(release?.bundle_id ?? "");
217
+ const bundle = bundleQuery.data;
218
+ const diagnostics = useReleaseCatalogDiagnosticsQuery(
219
+ release?.scope_key ?? "",
220
+ );
221
+ const update = useUpdateReleaseMutation();
222
+ const preflight = usePreflightReleaseMutation();
223
+ const remove = useDeleteReleaseMutation();
224
+ const promote = usePromoteReleaseMutation();
225
+ const [draft, setDraft] = useState<Draft | null>(null);
226
+ const [newCohort, setNewCohort] = useState("");
227
+ const [error, setError] = useState("");
228
+ const [confirmDiscard, setConfirmDiscard] = useState(false);
229
+ const [confirmDelete, setConfirmDelete] = useState(false);
230
+ const [showPromote, setShowPromote] = useState(false);
231
+ const [targetChannel, setTargetChannel] = useState("");
232
+ const [promoteAction, setPromoteAction] = useState<"copy" | "move">("move");
233
+
234
+ useEffect(() => {
235
+ setDraft(release ? draftFromRelease(release) : null);
236
+ setError("");
237
+ }, [release]);
238
+
239
+ const initialDraft = release ? draftFromRelease(release) : null;
240
+ const dirty =
241
+ draft !== null &&
242
+ initialDraft !== null &&
243
+ JSON.stringify(draft) !== JSON.stringify(initialDraft);
244
+ const busy =
245
+ preflight.isPending ||
246
+ update.isPending ||
247
+ remove.isPending ||
248
+ promote.isPending;
249
+
250
+ const requestClose = () => {
251
+ if (busy) return;
252
+ if (dirty) {
253
+ setConfirmDiscard(true);
254
+ return;
255
+ }
256
+ onOpenChange(false);
257
+ };
258
+
259
+ const addCohort = () => {
260
+ if (!draft) return;
261
+ const cohort = normalizeCohortValue(newCohort);
262
+ if (!isValidCohort(cohort)) {
263
+ setError(INVALID_COHORT_ERROR_MESSAGE);
264
+ return;
265
+ }
266
+ setDraft({
267
+ ...draft,
268
+ targetCohorts: Array.from(new Set([...draft.targetCohorts, cohort])),
269
+ });
270
+ setNewCohort("");
271
+ setError("");
272
+ };
273
+
274
+ const save = async () => {
275
+ if (!release || !draft) return;
276
+ const cohorts = draft.targetCohorts.map(normalizeCohortValue);
277
+ if (cohorts.some((cohort) => !isValidCohort(cohort))) {
278
+ setError(INVALID_COHORT_ERROR_MESSAGE);
279
+ return;
280
+ }
281
+ if (
282
+ release.strategy === "APP_VERSION" &&
283
+ !normalizeRange(draft.targetAppVersion.trim())
284
+ ) {
285
+ setError("Enter a valid target app version range.");
286
+ return;
287
+ }
288
+ const input = {
289
+ expectedRevision: release.revision,
290
+ patch: patchFromDraft(release, { ...draft, targetCohorts: cohorts }),
291
+ releaseId: release.id,
292
+ };
293
+ setError("");
294
+ try {
295
+ await preflight.mutateAsync(input);
296
+ await update.mutateAsync(input);
297
+ toast.success("Bundle settings saved");
298
+ } catch (caught) {
299
+ setError(
300
+ caught instanceof Error
301
+ ? caught.message
302
+ : "Bundle settings could not be saved.",
303
+ );
304
+ }
305
+ };
306
+
307
+ const channelName =
308
+ channels.find((channel) => channel.id === release?.channel_id)?.name ??
309
+ release?.channel_id;
310
+ const availableChannels = channels.filter(
311
+ (channel) => channel.id !== release?.channel_id,
312
+ );
313
+ const availableChannelItems = availableChannels.map((channel) => ({
314
+ label: channel.name,
315
+ value: channel.name,
316
+ }));
317
+ const normalizedTargetChannel = targetChannel.trim();
318
+ const isCopyPromotion = promoteAction === "copy";
319
+ const normalizedTargetAppVersion = draft
320
+ ? normalizeRange(draft.targetAppVersion.trim())
321
+ : null;
322
+
323
+ const resetPromoteDialog = () => {
324
+ setTargetChannel("");
325
+ setPromoteAction("move");
326
+ };
327
+
328
+ const handlePromoteOpenChange = (nextOpen: boolean) => {
329
+ if (!nextOpen && promote.isPending) return;
330
+ setShowPromote(nextOpen);
331
+ if (!nextOpen) resetPromoteDialog();
332
+ };
333
+
334
+ return (
335
+ <>
336
+ <Sheet
337
+ onOpenChange={(nextOpen) => (nextOpen ? undefined : requestClose())}
338
+ open={open}
339
+ >
340
+ <SheetContent className="min-w-0 overflow-hidden data-[side=right]:w-full sm:max-w-[600px]">
341
+ <SheetHeader className="shrink-0 pr-12">
342
+ <SheetTitle>Bundle Detail</SheetTitle>
343
+ <SheetDescription className="sr-only">
344
+ Edit bundle delivery settings.
345
+ </SheetDescription>
346
+ {release ? (
347
+ <div className="mt-1 flex min-w-0 flex-col gap-3 text-sm">
348
+ <div className="flex flex-wrap items-center gap-2">
349
+ <div className="flex items-center gap-2">
350
+ <PlatformIcon
351
+ className="size-4"
352
+ platform={release.platform}
353
+ />
354
+ <span className="font-medium">
355
+ {release.platform === "ios" ? "iOS" : "Android"}
356
+ </span>
357
+ </div>
358
+ </div>
359
+ <div className="flex flex-wrap items-start gap-2">
360
+ <span className="font-medium text-muted-foreground">
361
+ Bundle
362
+ </span>
363
+ <span className="min-w-0 basis-full sm:basis-auto">
364
+ {release.bundle_id ? (
365
+ <BundleIdDisplay
366
+ bundleId={release.bundle_id}
367
+ fullOnMobile
368
+ maxLength={18}
369
+ />
370
+ ) : (
371
+ <span className="text-xs text-foreground">
372
+ Built-in app
373
+ </span>
374
+ )}
375
+ </span>
376
+ </div>
377
+ <div className="flex flex-wrap items-center gap-2">
378
+ <span className="font-medium text-muted-foreground">
379
+ Channel
380
+ </span>
381
+ <span className="text-xs text-foreground" translate="no">
382
+ {channelName}
383
+ </span>
384
+ </div>
385
+ <div className="flex flex-wrap items-center gap-2">
386
+ <span className="font-medium text-muted-foreground">
387
+ Platform
388
+ </span>
389
+ <span className="text-xs text-foreground">
390
+ {release.platform === "ios" ? "iOS" : "Android"}
391
+ </span>
392
+ </div>
393
+ </div>
394
+ ) : (
395
+ <Skeleton className="h-12 w-full" />
396
+ )}
397
+ </SheetHeader>
398
+
399
+ <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
400
+ {releaseQuery.isError ? (
401
+ <Alert className="m-4 sm:m-6" variant="destructive">
402
+ <AlertTriangle />
403
+ <AlertTitle>Bundle details could not be loaded</AlertTitle>
404
+ <AlertDescription>
405
+ {releaseQuery.error.message}
406
+ </AlertDescription>
407
+ </Alert>
408
+ ) : null}
409
+
410
+ {release && draft ? (
411
+ <div className="flex flex-col gap-6 px-4 pb-4 sm:px-6 sm:pb-6">
412
+ {release.bundle_id ? (
413
+ <BundleInsightsSummary bundleId={release.bundle_id} />
414
+ ) : null}
415
+
416
+ <section
417
+ aria-labelledby="delivery-settings-heading"
418
+ className="flex flex-col gap-4"
419
+ >
420
+ <h3
421
+ className="text-sm font-medium"
422
+ id="delivery-settings-heading"
423
+ >
424
+ Delivery settings
425
+ </h3>
426
+ <FieldGroup className="gap-6">
427
+ <Field>
428
+ <FieldLabel htmlFor="release-message">Message</FieldLabel>
429
+ <Textarea
430
+ autoComplete="off"
431
+ id="release-message"
432
+ name="message"
433
+ onChange={(event) =>
434
+ setDraft({ ...draft, message: event.target.value })
435
+ }
436
+ rows={3}
437
+ value={draft.message}
438
+ />
439
+ </Field>
440
+ {release.strategy === "APP_VERSION" ? (
441
+ <Field>
442
+ <FieldLabel htmlFor="release-target">
443
+ Target app version
444
+ </FieldLabel>
445
+ <Input
446
+ autoComplete="off"
447
+ id="release-target"
448
+ name="targetAppVersion"
449
+ onChange={(event) =>
450
+ setDraft({
451
+ ...draft,
452
+ targetAppVersion: event.target.value,
453
+ })
454
+ }
455
+ value={draft.targetAppVersion}
456
+ />
457
+ {normalizedTargetAppVersion ? (
458
+ <FieldDescription>
459
+ {normalizedTargetAppVersion}
460
+ </FieldDescription>
461
+ ) : null}
462
+ </Field>
463
+ ) : null}
464
+ <Field>
465
+ <div className="flex items-center justify-between gap-4">
466
+ <FieldLabel htmlFor="release-enabled">
467
+ Enabled
468
+ </FieldLabel>
469
+ <Switch
470
+ aria-label="Enabled"
471
+ checked={draft.enabled}
472
+ id="release-enabled"
473
+ name="enabled"
474
+ onCheckedChange={(enabled) =>
475
+ setDraft({ ...draft, enabled })
476
+ }
477
+ />
478
+ </div>
479
+ <FieldDescription>
480
+ Disabling rolls devices back to the previous enabled
481
+ Release on their next check, or to the built-in app when
482
+ none remains.
483
+ </FieldDescription>
484
+ </Field>
485
+ <Field orientation="horizontal">
486
+ <FieldLabel htmlFor="release-force-update">
487
+ Force update
488
+ </FieldLabel>
489
+ <Switch
490
+ aria-label="Force update"
491
+ checked={draft.shouldForceUpdate}
492
+ id="release-force-update"
493
+ name="shouldForceUpdate"
494
+ onCheckedChange={(shouldForceUpdate) =>
495
+ setDraft({ ...draft, shouldForceUpdate })
496
+ }
497
+ />
498
+ </Field>
499
+ <Field>
500
+ <FieldLabel htmlFor="release-rollout-percentage">
501
+ Rollout percentage
502
+ </FieldLabel>
503
+ <RolloutPercentageInput
504
+ onChange={(rolloutCohortCount) =>
505
+ setDraft({ ...draft, rolloutCohortCount })
506
+ }
507
+ value={draft.rolloutCohortCount}
508
+ />
509
+ </Field>
510
+ <Field>
511
+ <div className="flex flex-wrap items-center justify-between gap-2">
512
+ <FieldLabel htmlFor="release-cohort">
513
+ Additional cohorts (optional)
514
+ </FieldLabel>
515
+ <RolloutCohortsDialog
516
+ releaseId={release.id}
517
+ rolloutCohortCount={draft.rolloutCohortCount}
518
+ targetCohorts={draft.targetCohorts}
519
+ />
520
+ </div>
521
+ <div className="flex gap-2">
522
+ <Input
523
+ autoComplete="off"
524
+ id="release-cohort"
525
+ name="targetCohort"
526
+ onChange={(event) => setNewCohort(event.target.value)}
527
+ onKeyDown={(event) => {
528
+ if (event.key === "Enter") {
529
+ event.preventDefault();
530
+ addCohort();
531
+ }
532
+ }}
533
+ placeholder="e.g. qa-team…"
534
+ value={newCohort}
535
+ />
536
+ <Button
537
+ aria-label="Add cohort"
538
+ disabled={!newCohort.trim()}
539
+ onClick={addCohort}
540
+ size="icon"
541
+ type="button"
542
+ variant="outline"
543
+ >
544
+ <Plus />
545
+ </Button>
546
+ </div>
547
+ {draft.targetCohorts.length ? (
548
+ <div className="flex flex-wrap gap-2">
549
+ {draft.targetCohorts.map((cohort) => (
550
+ <Badge
551
+ className="gap-1 font-mono"
552
+ key={cohort}
553
+ variant="secondary"
554
+ >
555
+ {cohort}
556
+ <button
557
+ aria-label={`Remove cohort ${cohort}`}
558
+ className="rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
559
+ onClick={() =>
560
+ setDraft({
561
+ ...draft,
562
+ targetCohorts: draft.targetCohorts.filter(
563
+ (value) => value !== cohort,
564
+ ),
565
+ })
566
+ }
567
+ type="button"
568
+ >
569
+ <X className="size-3" />
570
+ </button>
571
+ </Badge>
572
+ ))}
573
+ </div>
574
+ ) : null}
575
+ <FieldDescription>
576
+ Public names—no IDs or secrets.
577
+ </FieldDescription>
578
+ </Field>
579
+ </FieldGroup>
580
+ {error ? <FieldError>{error}</FieldError> : null}
581
+ <Button
582
+ className="w-full"
583
+ disabled={!dirty || busy}
584
+ onClick={save}
585
+ size="lg"
586
+ >
587
+ {preflight.isPending || update.isPending
588
+ ? "Saving…"
589
+ : "Save changes"}
590
+ </Button>
591
+ </section>
592
+
593
+ <Separator className="my-2" />
594
+
595
+ <section className="flex flex-col gap-4">
596
+ <h3 className="text-sm font-medium">Actions</h3>
597
+ <Button
598
+ className="w-full"
599
+ disabled={release.kind !== "BUNDLE" || busy}
600
+ onClick={() => setShowPromote(true)}
601
+ size="sm"
602
+ variant="outline"
603
+ >
604
+ Promote to channel
605
+ </Button>
606
+ {release.bundle_id ? (
607
+ <Button
608
+ className="w-full"
609
+ disabled={busy}
610
+ onClick={() => {
611
+ window.open(
612
+ `/api/bundles/${encodeURIComponent(release.bundle_id!)}/download`,
613
+ "_blank",
614
+ "noopener,noreferrer",
615
+ );
616
+ toast.success("Bundle download started");
617
+ }}
618
+ size="sm"
619
+ variant="outline"
620
+ >
621
+ <Download data-icon="inline-start" />
622
+ Download bundle
623
+ </Button>
624
+ ) : null}
625
+ <Button
626
+ className="w-full"
627
+ disabled={release.enabled || busy}
628
+ onClick={() => setConfirmDelete(true)}
629
+ size="sm"
630
+ variant="destructive"
631
+ >
632
+ Remove from channel
633
+ </Button>
634
+ </section>
635
+
636
+ {release.bundle_id && bundleQuery.isPending ? (
637
+ <Skeleton className="h-40 w-full" />
638
+ ) : null}
639
+ {release.bundle_id && bundleQuery.isError ? (
640
+ <Alert variant="destructive">
641
+ <AlertTriangle />
642
+ <AlertTitle>Build details are unavailable</AlertTitle>
643
+ <AlertDescription>
644
+ Delivery settings are unaffected.
645
+ </AlertDescription>
646
+ </Alert>
647
+ ) : null}
648
+ {!release.bundle_id || !bundleQuery.isPending ? (
649
+ <BundleMetadata bundle={bundle} release={release} />
650
+ ) : null}
651
+
652
+ <details className="rounded-lg border bg-muted/10 p-4 text-xs">
653
+ <summary className="cursor-pointer font-medium">
654
+ Advanced diagnostics
655
+ </summary>
656
+ <dl className="mt-4 grid gap-3 text-muted-foreground sm:grid-cols-2">
657
+ <div className="sm:col-span-2">
658
+ <dt>Release ID</dt>
659
+ <dd className="mt-1 break-all font-mono text-foreground">
660
+ {release.id}
661
+ </dd>
662
+ </div>
663
+ <div>
664
+ <dt>Revision</dt>
665
+ <dd className="mt-1 font-mono text-foreground">
666
+ {release.revision}
667
+ </dd>
668
+ </div>
669
+ <div>
670
+ <dt>Catalog generation</dt>
671
+ <dd className="mt-1 font-mono text-foreground">
672
+ {diagnostics.data?.generation ?? "—"}
673
+ </dd>
674
+ </div>
675
+ <div>
676
+ <dt>Catalog size</dt>
677
+ <dd className="mt-1 font-mono text-foreground">
678
+ {diagnostics.data
679
+ ? `${diagnostics.data.byte_size} bytes`
680
+ : "—"}
681
+ </dd>
682
+ </div>
683
+ <div className="sm:col-span-2">
684
+ <dt>Scope key</dt>
685
+ <dd className="mt-1 break-all font-mono text-foreground">
686
+ {release.scope_key}
687
+ </dd>
688
+ </div>
689
+ </dl>
690
+ {diagnostics.isError ? (
691
+ <p className="mt-3 text-destructive">
692
+ Diagnostics are unavailable. Bundle settings are
693
+ unaffected.
694
+ </p>
695
+ ) : null}
696
+ </details>
697
+ </div>
698
+ ) : null}
699
+ </div>
700
+ </SheetContent>
701
+ </Sheet>
702
+
703
+ <AlertDialog onOpenChange={setConfirmDiscard} open={confirmDiscard}>
704
+ <AlertDialogContent>
705
+ <AlertDialogHeader>
706
+ <AlertDialogTitle>Discard unsaved changes?</AlertDialogTitle>
707
+ <AlertDialogDescription>
708
+ Your Bundle changes will be lost.
709
+ </AlertDialogDescription>
710
+ </AlertDialogHeader>
711
+ <AlertDialogFooter>
712
+ <AlertDialogCancel>Keep editing</AlertDialogCancel>
713
+ <AlertDialogAction
714
+ onClick={() => {
715
+ setConfirmDiscard(false);
716
+ onOpenChange(false);
717
+ }}
718
+ variant="destructive"
719
+ >
720
+ Discard changes
721
+ </AlertDialogAction>
722
+ </AlertDialogFooter>
723
+ </AlertDialogContent>
724
+ </AlertDialog>
725
+
726
+ <AlertDialog onOpenChange={setConfirmDelete} open={confirmDelete}>
727
+ <AlertDialogContent>
728
+ <AlertDialogHeader>
729
+ <AlertDialogTitle>
730
+ Remove this Bundle from {channelName ?? "the channel"}?
731
+ </AlertDialogTitle>
732
+ <AlertDialogDescription>
733
+ The Bundle file stays available. This only removes it from the
734
+ channel.
735
+ </AlertDialogDescription>
736
+ </AlertDialogHeader>
737
+ <AlertDialogFooter>
738
+ <AlertDialogCancel>Cancel</AlertDialogCancel>
739
+ <AlertDialogAction
740
+ onClick={async () => {
741
+ if (!release) return;
742
+ try {
743
+ await remove.mutateAsync({
744
+ expectedRevision: release.revision,
745
+ releaseId: release.id,
746
+ });
747
+ toast.success(
748
+ `Bundle removed from ${channelName ?? "the channel"}`,
749
+ );
750
+ setConfirmDelete(false);
751
+ onOpenChange(false);
752
+ } catch (caught) {
753
+ setError(
754
+ caught instanceof Error
755
+ ? caught.message
756
+ : "Bundle could not be removed from the channel.",
757
+ );
758
+ }
759
+ }}
760
+ variant="destructive"
761
+ >
762
+ Remove from channel
763
+ </AlertDialogAction>
764
+ </AlertDialogFooter>
765
+ </AlertDialogContent>
766
+ </AlertDialog>
767
+
768
+ <Dialog onOpenChange={handlePromoteOpenChange} open={showPromote}>
769
+ <DialogContent showCloseButton={!promote.isPending}>
770
+ <DialogHeader>
771
+ <DialogTitle>Promote to Channel</DialogTitle>
772
+ <DialogDescription>
773
+ Choose how to promote this Bundle, then select the target channel.
774
+ </DialogDescription>
775
+ </DialogHeader>
776
+
777
+ <div className="space-y-4 py-4">
778
+ <div className="space-y-2">
779
+ <Label htmlFor="promote-action">Action</Label>
780
+ <Select
781
+ disabled={promote.isPending}
782
+ items={promoteActionItems}
783
+ onValueChange={(value) =>
784
+ setPromoteAction(value as "copy" | "move")
785
+ }
786
+ value={promoteAction}
787
+ >
788
+ <SelectTrigger className="w-full" id="promote-action">
789
+ <SelectValue placeholder="Select an action" />
790
+ </SelectTrigger>
791
+ <SelectContent>
792
+ <SelectGroup>
793
+ <SelectItem value="move">Move bundle</SelectItem>
794
+ <SelectItem value="copy">Copy bundle</SelectItem>
795
+ </SelectGroup>
796
+ </SelectContent>
797
+ </Select>
798
+ <p className="text-xs text-muted-foreground">
799
+ {isCopyPromotion
800
+ ? "Make this Bundle available in the target channel and keep it in the current channel."
801
+ : "Move this Bundle to the target channel. Devices in the current channel may fall back to an earlier Bundle."}
802
+ </p>
803
+ </div>
804
+
805
+ <div className="space-y-2">
806
+ <Label htmlFor="target-channel">Target Channel</Label>
807
+ <Select
808
+ disabled={promote.isPending}
809
+ items={availableChannelItems}
810
+ onValueChange={(value) => setTargetChannel(value ?? "")}
811
+ value={targetChannel || null}
812
+ >
813
+ <SelectTrigger className="w-full" id="target-channel">
814
+ <SelectValue placeholder="Select a channel" />
815
+ </SelectTrigger>
816
+ <SelectContent className="max-h-64">
817
+ <SelectGroup>
818
+ {availableChannels.map((channel) => (
819
+ <SelectItem key={channel.id} value={channel.name}>
820
+ {channel.name}
821
+ </SelectItem>
822
+ ))}
823
+ </SelectGroup>
824
+ </SelectContent>
825
+ </Select>
826
+ </div>
827
+ </div>
828
+
829
+ <DialogFooter>
830
+ <Button
831
+ disabled={promote.isPending}
832
+ onClick={() => handlePromoteOpenChange(false)}
833
+ variant="outline"
834
+ >
835
+ Cancel
836
+ </Button>
837
+ <Button
838
+ disabled={!normalizedTargetChannel || promote.isPending}
839
+ onClick={async () => {
840
+ if (!release) return;
841
+ try {
842
+ await promote.mutateAsync({
843
+ action: promoteAction,
844
+ expectedRevision: release.revision,
845
+ releaseId: release.id,
846
+ targetChannel: normalizedTargetChannel,
847
+ });
848
+ handlePromoteOpenChange(false);
849
+ toast.success(
850
+ isCopyPromotion
851
+ ? `Bundle copied to ${normalizedTargetChannel}`
852
+ : `Bundle moved to ${normalizedTargetChannel}`,
853
+ release.bundle_id
854
+ ? { description: `bundleId: ${release.bundle_id}` }
855
+ : undefined,
856
+ );
857
+ } catch (caught) {
858
+ toast.error(
859
+ caught instanceof Error
860
+ ? caught.message
861
+ : "Bundle could not be promoted.",
862
+ );
863
+ }
864
+ }}
865
+ >
866
+ {promote.isPending
867
+ ? isCopyPromotion
868
+ ? "Copying…"
869
+ : "Moving…"
870
+ : isCopyPromotion
871
+ ? "Copy"
872
+ : "Move"}
873
+ </Button>
874
+ </DialogFooter>
875
+ </DialogContent>
876
+ </Dialog>
877
+ </>
878
+ );
879
+ }