@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,228 @@
1
+ import { createUUIDv7, type ChannelRow } from "@hot-updater/plugin-core";
2
+ import { Loader2, Plus, Tag, Trash2 } from "lucide-react";
3
+ import { type FormEvent, useState } from "react";
4
+ import { toast } from "sonner";
5
+
6
+ import {
7
+ AlertDialog,
8
+ AlertDialogAction,
9
+ AlertDialogCancel,
10
+ AlertDialogContent,
11
+ AlertDialogDescription,
12
+ AlertDialogFooter,
13
+ AlertDialogHeader,
14
+ AlertDialogTitle,
15
+ } from "@/components/ui/alert-dialog";
16
+ import { Button } from "@/components/ui/button";
17
+ import {
18
+ Dialog,
19
+ DialogContent,
20
+ DialogDescription,
21
+ DialogHeader,
22
+ DialogTitle,
23
+ } from "@/components/ui/dialog";
24
+ import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
25
+ import { Input } from "@/components/ui/input";
26
+ import {
27
+ useChannelsQuery,
28
+ useCreateChannelMutation,
29
+ useDeleteChannelMutation,
30
+ } from "@/lib/api";
31
+
32
+ interface ChannelManagementDialogProps {
33
+ open: boolean;
34
+ onOpenChange: (open: boolean) => void;
35
+ }
36
+
37
+ export function ChannelManagementDialog({
38
+ open,
39
+ onOpenChange,
40
+ }: ChannelManagementDialogProps) {
41
+ const [channelName, setChannelName] = useState("");
42
+ const [channelToDelete, setChannelToDelete] = useState<ChannelRow | null>(
43
+ null,
44
+ );
45
+ const { data: channels = [], isPending: isLoadingChannels } =
46
+ useChannelsQuery();
47
+ const createChannel = useCreateChannelMutation();
48
+ const deleteChannel = useDeleteChannelMutation();
49
+ const normalizedName = channelName.trim();
50
+
51
+ const handleCreate = async (event: FormEvent<HTMLFormElement>) => {
52
+ event.preventDefault();
53
+ if (!normalizedName || createChannel.isPending) {
54
+ return;
55
+ }
56
+
57
+ try {
58
+ const result = await createChannel.mutateAsync({
59
+ row: { id: createUUIDv7(), name: normalizedName },
60
+ onConflict: "returnExisting",
61
+ });
62
+ setChannelName("");
63
+ if (result.inserted) {
64
+ toast.success(`Channel ${result.row.name} created`);
65
+ } else {
66
+ toast.info(`Channel ${result.row.name} already exists`);
67
+ }
68
+ } catch (error) {
69
+ toast.error("Failed to create channel");
70
+ console.error(error);
71
+ }
72
+ };
73
+
74
+ const handleDelete = async () => {
75
+ if (!channelToDelete || deleteChannel.isPending) {
76
+ return;
77
+ }
78
+
79
+ try {
80
+ const result = await deleteChannel.mutateAsync({
81
+ id: channelToDelete.id,
82
+ });
83
+
84
+ if (result.deleted) {
85
+ toast.success(`Channel ${channelToDelete.name} deleted`);
86
+ } else if (result.reason === "not_empty") {
87
+ toast.error(
88
+ `Channel ${channelToDelete.name} is still in use and cannot be deleted`,
89
+ );
90
+ } else {
91
+ toast.error(`Channel ${channelToDelete.name} no longer exists`);
92
+ }
93
+ setChannelToDelete(null);
94
+ } catch (error) {
95
+ toast.error("Failed to delete channel");
96
+ console.error(error);
97
+ }
98
+ };
99
+
100
+ return (
101
+ <>
102
+ <Dialog open={open} onOpenChange={onOpenChange}>
103
+ <DialogContent className="sm:max-w-lg">
104
+ <DialogHeader>
105
+ <DialogTitle className="flex items-center gap-2">
106
+ <Tag className="size-4 text-muted-foreground" />
107
+ Channels
108
+ </DialogTitle>
109
+ <DialogDescription>
110
+ Create deployment channels and remove them when no Release uses
111
+ them. Empty channels remain available until you delete them.
112
+ </DialogDescription>
113
+ </DialogHeader>
114
+
115
+ <form onSubmit={handleCreate}>
116
+ <FieldGroup className="gap-2">
117
+ <Field orientation="horizontal">
118
+ <FieldLabel htmlFor="channel-name" className="sr-only">
119
+ Channel name
120
+ </FieldLabel>
121
+ <Input
122
+ id="channel-name"
123
+ value={channelName}
124
+ onChange={(event) => setChannelName(event.target.value)}
125
+ placeholder="Channel name"
126
+ autoComplete="off"
127
+ disabled={createChannel.isPending}
128
+ />
129
+ <Button
130
+ type="submit"
131
+ disabled={!normalizedName || createChannel.isPending}
132
+ >
133
+ {createChannel.isPending ? (
134
+ <Loader2
135
+ data-icon="inline-start"
136
+ className="animate-spin"
137
+ />
138
+ ) : (
139
+ <Plus data-icon="inline-start" />
140
+ )}
141
+ Create
142
+ </Button>
143
+ </Field>
144
+ </FieldGroup>
145
+ </form>
146
+
147
+ <div className="max-h-72 overflow-y-auto rounded-lg border">
148
+ {isLoadingChannels ? (
149
+ <div className="flex h-20 items-center justify-center text-muted-foreground">
150
+ <Loader2 className="mr-2 size-3.5 animate-spin" />
151
+ Loading channels
152
+ </div>
153
+ ) : channels.length === 0 ? (
154
+ <div className="flex h-20 items-center justify-center text-muted-foreground">
155
+ No channels yet
156
+ </div>
157
+ ) : (
158
+ <ul className="divide-y">
159
+ {channels.map((channel) => (
160
+ <li
161
+ key={channel.id}
162
+ className="flex min-h-12 items-center gap-3 px-3 py-2"
163
+ >
164
+ <div className="min-w-0 flex-1">
165
+ <p className="truncate text-xs font-medium">
166
+ {channel.name}
167
+ </p>
168
+ <p className="truncate font-mono text-[10px] text-muted-foreground">
169
+ {channel.id}
170
+ </p>
171
+ </div>
172
+ <Button
173
+ type="button"
174
+ variant="ghost"
175
+ size="icon-sm"
176
+ aria-label={`Delete ${channel.name}`}
177
+ title={`Delete ${channel.name}`}
178
+ disabled={deleteChannel.isPending}
179
+ onClick={() => setChannelToDelete(channel)}
180
+ className="text-muted-foreground hover:text-destructive"
181
+ >
182
+ <Trash2 data-icon="inline-start" />
183
+ </Button>
184
+ </li>
185
+ ))}
186
+ </ul>
187
+ )}
188
+ </div>
189
+ </DialogContent>
190
+ </Dialog>
191
+
192
+ <AlertDialog
193
+ open={channelToDelete !== null}
194
+ onOpenChange={(nextOpen) => {
195
+ if (!nextOpen && !deleteChannel.isPending) {
196
+ setChannelToDelete(null);
197
+ }
198
+ }}
199
+ >
200
+ <AlertDialogContent>
201
+ <AlertDialogHeader>
202
+ <AlertDialogTitle>Delete {channelToDelete?.name}?</AlertDialogTitle>
203
+ <AlertDialogDescription>
204
+ Only empty channels can be removed. The server checks this when
205
+ you delete it, and the channel can be created again later with a
206
+ new ID.
207
+ </AlertDialogDescription>
208
+ </AlertDialogHeader>
209
+ <AlertDialogFooter>
210
+ <AlertDialogCancel disabled={deleteChannel.isPending}>
211
+ Cancel
212
+ </AlertDialogCancel>
213
+ <AlertDialogAction
214
+ variant="destructive"
215
+ disabled={deleteChannel.isPending}
216
+ onClick={(event) => {
217
+ event.preventDefault();
218
+ void handleDelete();
219
+ }}
220
+ >
221
+ {deleteChannel.isPending ? "Deleting..." : "Delete channel"}
222
+ </AlertDialogAction>
223
+ </AlertDialogFooter>
224
+ </AlertDialogContent>
225
+ </AlertDialog>
226
+ </>
227
+ );
228
+ }
@@ -0,0 +1,150 @@
1
+ import type {
2
+ ActiveInstallationOverview,
3
+ ActiveInstallationWindow,
4
+ } from "@hot-updater/server";
5
+ import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
6
+
7
+ import {
8
+ type ChartConfig,
9
+ ChartContainer,
10
+ ChartTooltip,
11
+ ChartTooltipContent,
12
+ } from "@/components/ui/chart";
13
+
14
+ type ActivitySeries = ActiveInstallationOverview["series"];
15
+
16
+ const dayFormatter = new Intl.DateTimeFormat("en", {
17
+ day: "numeric",
18
+ month: "short",
19
+ timeZone: "UTC",
20
+ });
21
+ const hourFormatter = new Intl.DateTimeFormat("en", {
22
+ day: "numeric",
23
+ hour: "numeric",
24
+ month: "short",
25
+ timeZone: "UTC",
26
+ });
27
+
28
+ const formatBucket = (
29
+ bucketStartMs: number,
30
+ window: ActiveInstallationWindow,
31
+ ): string =>
32
+ (window === "24h" ? hourFormatter : dayFormatter).format(
33
+ new Date(bucketStartMs),
34
+ );
35
+
36
+ const chartConfig = {
37
+ installations: {
38
+ label: "Active installations",
39
+ color: "var(--chart-2)",
40
+ },
41
+ } satisfies ChartConfig;
42
+
43
+ export function ActivityChart({
44
+ series,
45
+ window,
46
+ }: {
47
+ readonly series: ActivitySeries;
48
+ readonly window: ActiveInstallationWindow;
49
+ }) {
50
+ const chartData = series.map((point) => ({
51
+ bucketStartMs: point.bucketStartMs,
52
+ installations: point.value,
53
+ }));
54
+ const hasReports = series.some(({ value }) => value > 0);
55
+ const bucketName = window === "24h" ? "hour" : "day";
56
+
57
+ return (
58
+ <div className="flex min-w-0 flex-col gap-3">
59
+ <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
60
+ <div>
61
+ <h3 className="text-sm font-medium">Activity over time</h3>
62
+ <p className="text-xs text-muted-foreground">
63
+ Unique active installations per {bucketName}
64
+ </p>
65
+ </div>
66
+ <span className="text-xs text-muted-foreground">UTC</span>
67
+ </div>
68
+ {!hasReports && (
69
+ <p className="text-sm text-muted-foreground">
70
+ No installations reported during this period.
71
+ </p>
72
+ )}
73
+ <ChartContainer
74
+ aria-label={`Active installations per ${bucketName}`}
75
+ className="h-64 w-full aspect-auto"
76
+ config={chartConfig}
77
+ role="img"
78
+ >
79
+ <AreaChart
80
+ accessibilityLayer
81
+ data={chartData}
82
+ margin={{ left: -16, right: 4, top: 8 }}
83
+ >
84
+ <CartesianGrid vertical={false} />
85
+ <XAxis
86
+ axisLine={false}
87
+ dataKey="bucketStartMs"
88
+ minTickGap={28}
89
+ tickFormatter={(value: number) => formatBucket(value, window)}
90
+ tickLine={false}
91
+ />
92
+ <YAxis
93
+ allowDecimals={false}
94
+ axisLine={false}
95
+ tickLine={false}
96
+ width={32}
97
+ />
98
+ <ChartTooltip
99
+ content={
100
+ <ChartTooltipContent
101
+ labelFormatter={(_, payload) => {
102
+ const bucket = payload[0]?.payload.bucketStartMs;
103
+ return typeof bucket === "number"
104
+ ? formatBucket(bucket, window)
105
+ : "";
106
+ }}
107
+ />
108
+ }
109
+ />
110
+ <Area
111
+ dataKey="installations"
112
+ fill="var(--color-installations)"
113
+ fillOpacity={0.12}
114
+ isAnimationActive={false}
115
+ stroke="var(--color-installations)"
116
+ strokeWidth={2}
117
+ type="monotone"
118
+ />
119
+ </AreaChart>
120
+ </ChartContainer>
121
+ <p className="text-xs leading-relaxed text-muted-foreground">
122
+ Each point counts an installation once in that {bucketName}. The total
123
+ above counts it once across the whole period, so the points do not add
124
+ up to the total.
125
+ </p>
126
+ <div className="sr-only">
127
+ <table aria-label={`Exact active installations per ${bucketName}`}>
128
+ <caption>
129
+ Unique installations that reported an update status in each UTC
130
+ {` ${bucketName}`}.
131
+ </caption>
132
+ <thead>
133
+ <tr>
134
+ <th scope="col">UTC {bucketName}</th>
135
+ <th scope="col">Active installations</th>
136
+ </tr>
137
+ </thead>
138
+ <tbody>
139
+ {chartData.map((point) => (
140
+ <tr key={point.bucketStartMs}>
141
+ <th scope="row">{formatBucket(point.bucketStartMs, window)}</th>
142
+ <td>{point.installations}</td>
143
+ </tr>
144
+ ))}
145
+ </tbody>
146
+ </table>
147
+ </div>
148
+ </div>
149
+ );
150
+ }
@@ -0,0 +1,133 @@
1
+ import type { ActiveInstallationOverview } from "@hot-updater/server";
2
+ import { useState } from "react";
3
+
4
+ import { Button } from "@/components/ui/button";
5
+ import { Progress } from "@/components/ui/progress";
6
+ import {
7
+ Table,
8
+ TableBody,
9
+ TableCell,
10
+ TableHead,
11
+ TableHeader,
12
+ TableRow,
13
+ } from "@/components/ui/table";
14
+ import type { InsightsOverview } from "@/lib/insights-overview";
15
+
16
+ const PAGE_SIZE = 8;
17
+
18
+ const percentage = (value: number): string =>
19
+ new Intl.NumberFormat(undefined, {
20
+ maximumFractionDigits: 1,
21
+ style: "percent",
22
+ }).format(value);
23
+
24
+ export function BundleDistribution({
25
+ active,
26
+ catalog,
27
+ }: {
28
+ readonly active: ActiveInstallationOverview;
29
+ readonly catalog: InsightsOverview;
30
+ }) {
31
+ const [page, setPage] = useState(1);
32
+ const metadata = new Map(
33
+ catalog.configuredRollouts.map(({ bundle, bundleId }) => [
34
+ bundleId,
35
+ bundle,
36
+ ]),
37
+ );
38
+
39
+ if (active.bundles.length === 0) {
40
+ return (
41
+ <p className="py-6 text-center text-sm text-muted-foreground">
42
+ No observed installations in this range.
43
+ </p>
44
+ );
45
+ }
46
+
47
+ const totalPages = Math.ceil(active.bundles.length / PAGE_SIZE);
48
+ const currentPage = Math.min(page, totalPages);
49
+ const start = (currentPage - 1) * PAGE_SIZE;
50
+ const end = Math.min(start + PAGE_SIZE, active.bundles.length);
51
+
52
+ return (
53
+ <div className="flex min-w-0 flex-col gap-4">
54
+ <div className="overflow-x-auto">
55
+ <Table aria-label="Observed installations by bundle">
56
+ <TableHeader>
57
+ <TableRow className="hover:bg-transparent">
58
+ <TableHead>Bundle</TableHead>
59
+ <TableHead className="hidden w-2/5 sm:table-cell">
60
+ Activity
61
+ </TableHead>
62
+ <TableHead className="text-right">Observed</TableHead>
63
+ <TableHead className="text-right">Share</TableHead>
64
+ </TableRow>
65
+ </TableHeader>
66
+ <TableBody>
67
+ {active.bundles.slice(start, end).map((row) => {
68
+ const bundle = metadata.get(row.bundleId);
69
+ const share = row.installations / active.activeInstallations;
70
+ const shareLabel = percentage(share);
71
+ return (
72
+ <TableRow key={row.bundleId}>
73
+ <TableCell className="max-w-48 whitespace-normal">
74
+ <div className="flex min-w-0 flex-col gap-1">
75
+ <code className="break-all text-xs">{row.bundleId}</code>
76
+ <span className="text-muted-foreground">
77
+ {bundle
78
+ ? `${bundle.platform} · ${bundle.channel}`
79
+ : "Unknown bundle metadata"}
80
+ </span>
81
+ </div>
82
+ </TableCell>
83
+ <TableCell className="hidden sm:table-cell">
84
+ <Progress
85
+ aria-label={`${row.bundleId} observed share ${shareLabel}`}
86
+ aria-valuetext={shareLabel}
87
+ className="h-2"
88
+ value={share * 100}
89
+ />
90
+ </TableCell>
91
+ <TableCell className="text-right tabular-nums">
92
+ {row.installations.toLocaleString()}
93
+ </TableCell>
94
+ <TableCell className="text-right tabular-nums">
95
+ {shareLabel}
96
+ </TableCell>
97
+ </TableRow>
98
+ );
99
+ })}
100
+ </TableBody>
101
+ </Table>
102
+ </div>
103
+ {totalPages > 1 && (
104
+ <div className="flex flex-wrap items-center justify-between gap-2 px-4 pb-4">
105
+ <p className="text-xs text-muted-foreground tabular-nums">
106
+ Showing {start + 1}–{end} of{" "}
107
+ {active.bundles.length.toLocaleString()}
108
+ </p>
109
+ <div className="flex gap-2">
110
+ <Button
111
+ disabled={currentPage === 1}
112
+ onClick={() => setPage(currentPage - 1)}
113
+ size="sm"
114
+ type="button"
115
+ variant="outline"
116
+ >
117
+ Previous
118
+ </Button>
119
+ <Button
120
+ disabled={currentPage === totalPages}
121
+ onClick={() => setPage(currentPage + 1)}
122
+ size="sm"
123
+ type="button"
124
+ variant="outline"
125
+ >
126
+ Next
127
+ </Button>
128
+ </div>
129
+ </div>
130
+ )}
131
+ </div>
132
+ );
133
+ }
@@ -0,0 +1,114 @@
1
+ import { Button } from "@/components/ui/button";
2
+ import {
3
+ Combobox,
4
+ ComboboxContent,
5
+ ComboboxEmpty,
6
+ ComboboxInput,
7
+ ComboboxItem,
8
+ ComboboxList,
9
+ ComboboxTrigger,
10
+ } from "@/components/ui/combobox";
11
+ import { Field, FieldLabel } from "@/components/ui/field";
12
+
13
+ type BundleOption = {
14
+ readonly bundleId: string;
15
+ readonly description: string;
16
+ };
17
+
18
+ export function BundleSelector({
19
+ bundleId,
20
+ bundles,
21
+ onBundleChange,
22
+ }: {
23
+ readonly bundleId: string;
24
+ readonly bundles: readonly BundleOption[];
25
+ readonly onBundleChange: (bundleId: string) => void;
26
+ }) {
27
+ const selectedBundle =
28
+ bundles.find((bundle) => bundle.bundleId === bundleId) ?? null;
29
+
30
+ return (
31
+ <Field
32
+ className="w-full min-w-0 sm:max-w-md"
33
+ data-disabled={!bundles.length}
34
+ >
35
+ <FieldLabel htmlFor="insights-bundle-selector">
36
+ Bundle to inspect
37
+ </FieldLabel>
38
+ <Combobox
39
+ autoHighlight
40
+ disabled={!bundles.length}
41
+ filter={(bundle, query) => {
42
+ const normalizedQuery = query.trim().toLowerCase();
43
+ return (
44
+ bundle.bundleId.toLowerCase().includes(normalizedQuery) ||
45
+ bundle.description.toLowerCase().includes(normalizedQuery)
46
+ );
47
+ }}
48
+ isItemEqualToValue={(bundle, value) =>
49
+ bundle.bundleId === value.bundleId
50
+ }
51
+ items={bundles}
52
+ itemToStringLabel={(bundle) => bundle.bundleId}
53
+ onValueChange={(bundle) => {
54
+ if (bundle) onBundleChange(bundle.bundleId);
55
+ }}
56
+ value={selectedBundle}
57
+ >
58
+ <ComboboxTrigger
59
+ aria-label="Bundle to inspect"
60
+ id="insights-bundle-selector"
61
+ render={
62
+ <Button
63
+ className="h-auto min-h-12 w-full min-w-0 justify-between px-3 py-2 text-left"
64
+ size="lg"
65
+ variant="outline"
66
+ />
67
+ }
68
+ >
69
+ {selectedBundle ? (
70
+ <span className="flex min-w-0 flex-1 flex-col items-start gap-1">
71
+ <code className="max-w-full truncate text-xs/4">
72
+ {selectedBundle.bundleId}
73
+ </code>
74
+ <span className="max-w-full truncate text-xs/4 text-muted-foreground">
75
+ {selectedBundle.description}
76
+ </span>
77
+ </span>
78
+ ) : (
79
+ <span className="truncate text-muted-foreground">
80
+ No bundles available
81
+ </span>
82
+ )}
83
+ </ComboboxTrigger>
84
+ <ComboboxContent className="min-w-(--anchor-width)">
85
+ <ComboboxInput
86
+ aria-label="Search bundles"
87
+ placeholder="Search by bundle ID or description"
88
+ showClear
89
+ showTrigger={false}
90
+ />
91
+ <ComboboxEmpty>No bundles found.</ComboboxEmpty>
92
+ <ComboboxList>
93
+ {(bundle) => (
94
+ <ComboboxItem
95
+ className="min-h-12 px-3 py-2"
96
+ key={bundle.bundleId}
97
+ value={bundle}
98
+ >
99
+ <span className="flex min-w-0 flex-col gap-1 pr-5">
100
+ <code className="max-w-72 truncate text-xs/4">
101
+ {bundle.bundleId}
102
+ </code>
103
+ <span className="truncate text-xs/4 text-muted-foreground">
104
+ {bundle.description}
105
+ </span>
106
+ </span>
107
+ </ComboboxItem>
108
+ )}
109
+ </ComboboxList>
110
+ </ComboboxContent>
111
+ </Combobox>
112
+ </Field>
113
+ );
114
+ }
@@ -0,0 +1,24 @@
1
+ import { createContext, useContext, type ReactNode } from "react";
2
+
3
+ import type { InsightsCapabilityState } from "@/lib/insights-api";
4
+
5
+ const InsightsCapabilityContext = createContext<InsightsCapabilityState>({
6
+ status: "unresolved",
7
+ });
8
+
9
+ export function InsightsCapabilityProvider({
10
+ children,
11
+ value,
12
+ }: {
13
+ readonly children: ReactNode;
14
+ readonly value: InsightsCapabilityState;
15
+ }) {
16
+ return (
17
+ <InsightsCapabilityContext.Provider value={value}>
18
+ {children}
19
+ </InsightsCapabilityContext.Provider>
20
+ );
21
+ }
22
+
23
+ export const useInsightsCapability = (): InsightsCapabilityState =>
24
+ useContext(InsightsCapabilityContext);