@hot-updater/console 0.36.6 → 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 +249 -46
  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,377 @@
1
+ import {
2
+ AlertTriangle,
3
+ Check,
4
+ Clipboard,
5
+ KeyRound,
6
+ Loader2,
7
+ Plus,
8
+ RefreshCw,
9
+ ShieldOff,
10
+ } from "lucide-react";
11
+ import { type FormEvent, type MouseEvent, useState } from "react";
12
+ import { toast } from "sonner";
13
+
14
+ import {
15
+ Alert,
16
+ AlertAction,
17
+ AlertDescription,
18
+ AlertTitle,
19
+ } from "@/components/ui/alert";
20
+ import {
21
+ AlertDialog,
22
+ AlertDialogAction,
23
+ AlertDialogCancel,
24
+ AlertDialogContent,
25
+ AlertDialogDescription,
26
+ AlertDialogFooter,
27
+ AlertDialogHeader,
28
+ AlertDialogMedia,
29
+ AlertDialogTitle,
30
+ AlertDialogTrigger,
31
+ } from "@/components/ui/alert-dialog";
32
+ import { Badge } from "@/components/ui/badge";
33
+ import { Button } from "@/components/ui/button";
34
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
35
+ import {
36
+ Dialog,
37
+ DialogContent,
38
+ DialogDescription,
39
+ DialogFooter,
40
+ DialogHeader,
41
+ DialogTitle,
42
+ } from "@/components/ui/dialog";
43
+ import {
44
+ Field,
45
+ FieldDescription,
46
+ FieldError,
47
+ FieldGroup,
48
+ FieldLabel,
49
+ } from "@/components/ui/field";
50
+ import { Input } from "@/components/ui/input";
51
+ import { Skeleton } from "@/components/ui/skeleton";
52
+ import {
53
+ Table,
54
+ TableBody,
55
+ TableCell,
56
+ TableHead,
57
+ TableHeader,
58
+ TableRow,
59
+ } from "@/components/ui/table";
60
+ import {
61
+ useApiKeysQuery,
62
+ useCreateApiKeyMutation,
63
+ useRevokeApiKeyMutation,
64
+ } from "@/lib/api-keys-api";
65
+ import type { ApiKeyView } from "@/lib/api-keys-rpc";
66
+
67
+ const formatCreatedAt = (createdAt: number): string =>
68
+ new Date(createdAt).toISOString().slice(0, 10);
69
+
70
+ function CreateApiKeyDialog() {
71
+ const [open, setOpen] = useState(false);
72
+ const [name, setName] = useState("");
73
+ const [apiKey, setApiKey] = useState<string | null>(null);
74
+ const create = useCreateApiKeyMutation();
75
+ const normalizedName = name.trim();
76
+ const nameError =
77
+ normalizedName.length > 64 ? "Name must be 64 characters or fewer." : null;
78
+
79
+ const reset = () => {
80
+ setName("");
81
+ setApiKey(null);
82
+ create.reset();
83
+ };
84
+
85
+ const handleOpenChange = (nextOpen: boolean) => {
86
+ setOpen(nextOpen);
87
+ if (!nextOpen) reset();
88
+ };
89
+
90
+ const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
91
+ event.preventDefault();
92
+ if (normalizedName.length === 0 || nameError !== null) return;
93
+ try {
94
+ const result = await create.mutateAsync(normalizedName);
95
+ setApiKey(result.apiKey);
96
+ toast.success("API key created");
97
+ } catch (error) {
98
+ toast.error(
99
+ error instanceof Error ? error.message : "Unable to create API key",
100
+ );
101
+ }
102
+ };
103
+
104
+ const copyKey = async () => {
105
+ if (apiKey === null) return;
106
+ try {
107
+ await navigator.clipboard.writeText(apiKey);
108
+ toast.success("API key copied");
109
+ } catch {
110
+ toast.error("Unable to copy API key");
111
+ }
112
+ };
113
+
114
+ return (
115
+ <>
116
+ <Button onClick={() => setOpen(true)}>
117
+ <Plus data-icon="inline-start" />
118
+ Create API key
119
+ </Button>
120
+ <Dialog open={open} onOpenChange={handleOpenChange}>
121
+ <DialogContent showCloseButton={apiKey === null}>
122
+ <DialogHeader>
123
+ <DialogTitle>
124
+ {apiKey === null ? "Create API key" : "Save your API key"}
125
+ </DialogTitle>
126
+ <DialogDescription>
127
+ {apiKey === null
128
+ ? "Name the app or environment that will use this key."
129
+ : "This key is shown once. Store it securely before closing."}
130
+ </DialogDescription>
131
+ </DialogHeader>
132
+ {apiKey === null ? (
133
+ <form onSubmit={handleSubmit}>
134
+ <FieldGroup>
135
+ <Field data-invalid={nameError !== null || undefined}>
136
+ <FieldLabel htmlFor="api-key-name">Name</FieldLabel>
137
+ <Input
138
+ id="api-key-name"
139
+ autoComplete="off"
140
+ maxLength={65}
141
+ onChange={(event) => setName(event.target.value)}
142
+ placeholder="Production app"
143
+ value={name}
144
+ />
145
+ <FieldDescription>
146
+ Use a name you can identify before revoking the key.
147
+ </FieldDescription>
148
+ <FieldError>{nameError}</FieldError>
149
+ </Field>
150
+ </FieldGroup>
151
+ <DialogFooter className="mt-4">
152
+ <Button
153
+ disabled={
154
+ create.isPending ||
155
+ normalizedName.length === 0 ||
156
+ nameError !== null
157
+ }
158
+ type="submit"
159
+ >
160
+ {create.isPending ? (
161
+ <Loader2
162
+ data-icon="inline-start"
163
+ className="animate-spin"
164
+ />
165
+ ) : (
166
+ <KeyRound data-icon="inline-start" />
167
+ )}
168
+ Create API key
169
+ </Button>
170
+ </DialogFooter>
171
+ </form>
172
+ ) : (
173
+ <FieldGroup>
174
+ <Field>
175
+ <FieldLabel htmlFor="created-api-key">API key</FieldLabel>
176
+ <div className="flex gap-2">
177
+ <Input
178
+ id="created-api-key"
179
+ className="font-mono"
180
+ readOnly
181
+ value={apiKey}
182
+ />
183
+ <Button onClick={copyKey} type="button" variant="outline">
184
+ <Clipboard data-icon="inline-start" />
185
+ Copy
186
+ </Button>
187
+ </div>
188
+ </Field>
189
+ <DialogFooter>
190
+ <Button onClick={() => handleOpenChange(false)}>
191
+ <Check data-icon="inline-start" />
192
+ Done
193
+ </Button>
194
+ </DialogFooter>
195
+ </FieldGroup>
196
+ )}
197
+ </DialogContent>
198
+ </Dialog>
199
+ </>
200
+ );
201
+ }
202
+
203
+ function RevokeApiKeyDialog({ record }: { record: ApiKeyView }) {
204
+ const [open, setOpen] = useState(false);
205
+ const revoke = useRevokeApiKeyMutation();
206
+
207
+ const handleRevoke = async (event: MouseEvent<HTMLButtonElement>) => {
208
+ event.preventDefault();
209
+ try {
210
+ await revoke.mutateAsync(record.id);
211
+ setOpen(false);
212
+ toast.success("API key revoked");
213
+ } catch (error) {
214
+ toast.error(
215
+ error instanceof Error ? error.message : "Unable to revoke API key",
216
+ );
217
+ }
218
+ };
219
+
220
+ return (
221
+ <AlertDialog
222
+ open={open}
223
+ onOpenChange={(nextOpen) => {
224
+ if (!revoke.isPending) setOpen(nextOpen);
225
+ }}
226
+ >
227
+ <AlertDialogTrigger render={<Button size="sm" variant="ghost" />}>
228
+ <ShieldOff data-icon="inline-start" />
229
+ Revoke
230
+ </AlertDialogTrigger>
231
+ <AlertDialogContent>
232
+ <AlertDialogHeader>
233
+ <AlertDialogMedia>
234
+ <ShieldOff />
235
+ </AlertDialogMedia>
236
+ <AlertDialogTitle>Revoke {record.name}?</AlertDialogTitle>
237
+ <AlertDialogDescription>
238
+ Apps using {record.prefix}… will immediately lose OTA update and
239
+ analytics access. This action cannot be undone.
240
+ </AlertDialogDescription>
241
+ </AlertDialogHeader>
242
+ <AlertDialogFooter>
243
+ <AlertDialogCancel disabled={revoke.isPending}>
244
+ Cancel
245
+ </AlertDialogCancel>
246
+ <AlertDialogAction
247
+ disabled={revoke.isPending}
248
+ onClick={(event) => void handleRevoke(event)}
249
+ variant="destructive"
250
+ >
251
+ Revoke API key
252
+ </AlertDialogAction>
253
+ </AlertDialogFooter>
254
+ </AlertDialogContent>
255
+ </AlertDialog>
256
+ );
257
+ }
258
+
259
+ export function ApiKeysPage() {
260
+ const apiKeys = useApiKeysQuery();
261
+
262
+ return (
263
+ <Card className="overflow-hidden shadow-sm">
264
+ <CardHeader className="flex-row items-center justify-between space-y-0 border-b px-4 py-3">
265
+ <CardTitle className="text-sm">
266
+ <h2>API keys</h2>
267
+ </CardTitle>
268
+ <CreateApiKeyDialog />
269
+ </CardHeader>
270
+ <CardContent className="p-0">
271
+ {apiKeys.isError ? (
272
+ <div className="p-4">
273
+ <Alert variant="destructive">
274
+ <AlertTriangle />
275
+ <AlertTitle>API keys couldn't be loaded</AlertTitle>
276
+ <AlertDescription>
277
+ Check your connection and try again.
278
+ </AlertDescription>
279
+ <AlertAction>
280
+ <Button
281
+ onClick={() => void apiKeys.refetch()}
282
+ size="xs"
283
+ variant="outline"
284
+ >
285
+ <RefreshCw data-icon="inline-start" />
286
+ Retry
287
+ </Button>
288
+ </AlertAction>
289
+ </Alert>
290
+ </div>
291
+ ) : (
292
+ <Table aria-label="API keys" className="table-fixed">
293
+ <TableHeader>
294
+ <TableRow>
295
+ <TableHead>Key</TableHead>
296
+ <TableHead className="w-32">Created</TableHead>
297
+ <TableHead className="w-24 text-right">
298
+ <span className="sr-only">Actions</span>
299
+ </TableHead>
300
+ </TableRow>
301
+ </TableHeader>
302
+ <TableBody>
303
+ {apiKeys.isLoading ? (
304
+ Array.from({ length: 3 }, (_, index) => (
305
+ <TableRow key={index}>
306
+ <TableCell className="space-y-2 py-3">
307
+ {index === 0 ? (
308
+ <span className="sr-only">Loading API keys</span>
309
+ ) : null}
310
+ <Skeleton className="h-3.5 w-32" />
311
+ <Skeleton className="h-3 w-20" />
312
+ </TableCell>
313
+ <TableCell>
314
+ <Skeleton className="h-3.5 w-20" />
315
+ </TableCell>
316
+ <TableCell>
317
+ <Skeleton className="ml-auto h-6 w-16" />
318
+ </TableCell>
319
+ </TableRow>
320
+ ))
321
+ ) : apiKeys.data?.length === 0 ? (
322
+ <TableRow>
323
+ <TableCell
324
+ colSpan={3}
325
+ className="h-40 text-center text-muted-foreground"
326
+ >
327
+ <div className="mx-auto flex max-w-xs flex-col items-center gap-2">
328
+ <KeyRound className="size-5" />
329
+ <p className="font-medium text-foreground">No API keys</p>
330
+ <p>Create an API key to connect an app.</p>
331
+ </div>
332
+ </TableCell>
333
+ </TableRow>
334
+ ) : (
335
+ apiKeys.data?.map((apiKey) => (
336
+ <TableRow
337
+ className={
338
+ apiKey.revoked_at_ms === null
339
+ ? undefined
340
+ : "text-muted-foreground"
341
+ }
342
+ key={apiKey.id}
343
+ >
344
+ <TableCell className="whitespace-normal py-3">
345
+ <div className="min-w-0 space-y-1">
346
+ <p className="break-words font-medium text-foreground">
347
+ {apiKey.name}
348
+ </p>
349
+ <p className="font-mono text-xs text-muted-foreground">
350
+ {apiKey.prefix}…
351
+ </p>
352
+ </div>
353
+ </TableCell>
354
+ <TableCell className="text-xs text-muted-foreground">
355
+ <time
356
+ dateTime={new Date(apiKey.created_at_ms).toISOString()}
357
+ >
358
+ {formatCreatedAt(apiKey.created_at_ms)}
359
+ </time>
360
+ </TableCell>
361
+ <TableCell className="text-right">
362
+ {apiKey.revoked_at_ms === null ? (
363
+ <RevokeApiKeyDialog record={apiKey} />
364
+ ) : (
365
+ <Badge variant="outline">Revoked</Badge>
366
+ )}
367
+ </TableCell>
368
+ </TableRow>
369
+ ))
370
+ )}
371
+ </TableBody>
372
+ </Table>
373
+ )}
374
+ </CardContent>
375
+ </Card>
376
+ );
377
+ }
@@ -0,0 +1,193 @@
1
+ import type { ActiveInstallationWindow } from "@hot-updater/server";
2
+ import { useId } from "react";
3
+ import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
4
+
5
+ import {
6
+ type ChartConfig,
7
+ ChartContainer,
8
+ ChartTooltip,
9
+ ChartTooltipContent,
10
+ } from "@/components/ui/chart";
11
+
12
+ interface SeriesPoint {
13
+ readonly bucketStartMs: number;
14
+ readonly value: number;
15
+ }
16
+
17
+ interface BundleActivityChartProps {
18
+ readonly installed: readonly SeriesPoint[];
19
+ readonly recovered: readonly SeriesPoint[];
20
+ readonly window: ActiveInstallationWindow;
21
+ }
22
+
23
+ interface ActivityChartPoint {
24
+ readonly bucketStartMs: number;
25
+ readonly installed: number;
26
+ readonly recovered: number;
27
+ }
28
+
29
+ const chartConfig = {
30
+ installed: {
31
+ label: "Applied",
32
+ color: "var(--chart-2)",
33
+ },
34
+ recovered: {
35
+ label: "Recovered",
36
+ color: "var(--muted-foreground)",
37
+ },
38
+ } satisfies ChartConfig;
39
+
40
+ const dateFormatter = new Intl.DateTimeFormat("en", {
41
+ day: "numeric",
42
+ month: "short",
43
+ timeZone: "UTC",
44
+ });
45
+ const hourFormatter = new Intl.DateTimeFormat("en", {
46
+ day: "numeric",
47
+ hour: "numeric",
48
+ month: "short",
49
+ timeZone: "UTC",
50
+ });
51
+
52
+ const windowLabels = {
53
+ "24h": "24 hours",
54
+ "7d": "7 days",
55
+ "30d": "30 days",
56
+ } as const;
57
+
58
+ const mergeActivitySeries = (
59
+ installed: readonly SeriesPoint[],
60
+ recovered: readonly SeriesPoint[],
61
+ ): readonly ActivityChartPoint[] => {
62
+ const installedByBucket = new Map(
63
+ installed.map((point) => [point.bucketStartMs, point.value] as const),
64
+ );
65
+ const recoveredByBucket = new Map(
66
+ recovered.map((point) => [point.bucketStartMs, point.value] as const),
67
+ );
68
+ const buckets = new Set([
69
+ ...installedByBucket.keys(),
70
+ ...recoveredByBucket.keys(),
71
+ ]);
72
+
73
+ return [...buckets]
74
+ .sort((left, right) => left - right)
75
+ .map((bucketStartMs) => ({
76
+ bucketStartMs,
77
+ installed: installedByBucket.get(bucketStartMs) ?? 0,
78
+ recovered: recoveredByBucket.get(bucketStartMs) ?? 0,
79
+ }));
80
+ };
81
+
82
+ const formatBucket = (
83
+ value: number,
84
+ window: ActiveInstallationWindow,
85
+ ): string =>
86
+ (window === "24h" ? hourFormatter : dateFormatter).format(new Date(value));
87
+
88
+ export function BundleActivityChart({
89
+ installed,
90
+ recovered,
91
+ window,
92
+ }: BundleActivityChartProps) {
93
+ const captionId = useId();
94
+ const chartData = mergeActivitySeries(installed, recovered);
95
+ const hasWindowActivity = chartData.some(
96
+ (point) => point.installed > 0 || point.recovered > 0,
97
+ );
98
+
99
+ if (!hasWindowActivity) {
100
+ return (
101
+ <div className="flex h-16 items-center justify-center border-t px-4 pt-3 text-center text-xs text-muted-foreground sm:h-20 sm:pt-4">
102
+ No device activity in the last 30 days.
103
+ </div>
104
+ );
105
+ }
106
+
107
+ return (
108
+ <div className="border-t pt-3 sm:pt-4">
109
+ <ChartContainer
110
+ aria-describedby={captionId}
111
+ aria-label={`Bundle movement over ${windowLabels[window]}`}
112
+ className="h-24 w-full aspect-auto sm:h-32"
113
+ config={chartConfig}
114
+ role="img"
115
+ >
116
+ <AreaChart
117
+ accessibilityLayer
118
+ data={chartData}
119
+ margin={{ left: -16, right: 4, top: 4 }}
120
+ >
121
+ <CartesianGrid vertical={false} />
122
+ <XAxis
123
+ axisLine={false}
124
+ dataKey="bucketStartMs"
125
+ minTickGap={28}
126
+ tickFormatter={(value: number) => formatBucket(value, window)}
127
+ tickLine={false}
128
+ />
129
+ <YAxis
130
+ allowDecimals={false}
131
+ axisLine={false}
132
+ tickLine={false}
133
+ width={32}
134
+ />
135
+ <ChartTooltip
136
+ content={
137
+ <ChartTooltipContent
138
+ labelFormatter={(_, payload) => {
139
+ const bucket = payload[0]?.payload.bucketStartMs;
140
+ return typeof bucket === "number"
141
+ ? formatBucket(bucket, window)
142
+ : "";
143
+ }}
144
+ />
145
+ }
146
+ />
147
+ <Area
148
+ dataKey="installed"
149
+ fill="var(--color-installed)"
150
+ fillOpacity={0.12}
151
+ isAnimationActive={false}
152
+ stroke="var(--color-installed)"
153
+ strokeWidth={2}
154
+ type="monotone"
155
+ />
156
+ <Area
157
+ dataKey="recovered"
158
+ fill="var(--color-recovered)"
159
+ fillOpacity={0.04}
160
+ isAnimationActive={false}
161
+ stroke="var(--color-recovered)"
162
+ strokeWidth={1.5}
163
+ type="monotone"
164
+ />
165
+ </AreaChart>
166
+ </ChartContainer>
167
+ <div className="sr-only">
168
+ <table>
169
+ <caption id={captionId}>
170
+ Distinct bundle movement in each bucket over {windowLabels[window]}.
171
+ Times are shown in UTC.
172
+ </caption>
173
+ <thead>
174
+ <tr>
175
+ <th scope="col">Date</th>
176
+ <th scope="col">Applied</th>
177
+ <th scope="col">Recovered</th>
178
+ </tr>
179
+ </thead>
180
+ <tbody>
181
+ {chartData.map((point) => (
182
+ <tr key={point.bucketStartMs}>
183
+ <th scope="row">{formatBucket(point.bucketStartMs, window)}</th>
184
+ <td>{point.installed}</td>
185
+ <td>{point.recovered}</td>
186
+ </tr>
187
+ ))}
188
+ </tbody>
189
+ </table>
190
+ </div>
191
+ </div>
192
+ );
193
+ }
@@ -0,0 +1,123 @@
1
+ import { useAnalyticsCapability } from "@/components/features/analytics/AnalyticsCapabilityContext";
2
+ import { AnalyticsErrorAlert } from "@/components/features/analytics/AnalyticsErrorAlert";
3
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
4
+ import { Skeleton } from "@/components/ui/skeleton";
5
+ import { useBundleEventAnalyticsQuery } from "@/lib/api";
6
+
7
+ import { BundleActivityChart } from "./BundleActivityChart";
8
+
9
+ interface BundleAnalyticsSummaryProps {
10
+ readonly bundleId: string;
11
+ }
12
+
13
+ function Metric({
14
+ label,
15
+ tone,
16
+ value,
17
+ }: {
18
+ readonly label: string;
19
+ readonly tone: "applied" | "recovered";
20
+ readonly value: number;
21
+ }) {
22
+ return (
23
+ <div className="flex flex-col gap-1">
24
+ <dt className="flex items-center gap-2 text-xs text-muted-foreground">
25
+ <span
26
+ aria-hidden="true"
27
+ className={
28
+ tone === "applied"
29
+ ? "size-2 rounded-full bg-chart-2"
30
+ : "size-2 rounded-full bg-muted-foreground"
31
+ }
32
+ />
33
+ {label}
34
+ </dt>
35
+ <dd className="text-xl font-semibold tracking-tight tabular-nums">
36
+ {value}
37
+ </dd>
38
+ </div>
39
+ );
40
+ }
41
+
42
+ function SupportedBundleAnalyticsSummary({
43
+ bundleId,
44
+ }: BundleAnalyticsSummaryProps) {
45
+ const { data, error, isLoading } = useBundleEventAnalyticsQuery(
46
+ {
47
+ bundleId,
48
+ window: "30d",
49
+ limit: 1,
50
+ offset: 0,
51
+ },
52
+ true,
53
+ );
54
+
55
+ return (
56
+ <Card>
57
+ <CardHeader className="p-4 pb-3">
58
+ <CardTitle className="text-sm font-medium">
59
+ Activity · 30 days
60
+ </CardTitle>
61
+ </CardHeader>
62
+ <CardContent className="flex flex-col gap-3 px-4 pb-4">
63
+ {isLoading ? (
64
+ <div
65
+ aria-label="Loading reported bundle outcomes"
66
+ className="flex flex-col gap-3"
67
+ >
68
+ <div className="grid grid-cols-2 gap-4">
69
+ <Skeleton className="h-10 w-full" />
70
+ <Skeleton className="h-10 w-full" />
71
+ </div>
72
+ <Skeleton className="h-24 w-full sm:h-32" />
73
+ </div>
74
+ ) : error ? (
75
+ <AnalyticsErrorAlert
76
+ error={
77
+ error instanceof Error
78
+ ? error
79
+ : new Error("Failed to load bundle analytics.")
80
+ }
81
+ fallbackTitle="Analytics unavailable"
82
+ />
83
+ ) : (
84
+ <>
85
+ <dl className="grid grid-cols-2 divide-x divide-border/70">
86
+ <div className="pr-4">
87
+ <Metric
88
+ label="Applied"
89
+ tone="applied"
90
+ value={data?.summary.installed ?? 0}
91
+ />
92
+ </div>
93
+ <div className="pl-4">
94
+ <Metric
95
+ label="Recovered"
96
+ tone="recovered"
97
+ value={data?.summary.recovered ?? 0}
98
+ />
99
+ </div>
100
+ </dl>
101
+ <BundleActivityChart
102
+ installed={data?.series.installed ?? []}
103
+ recovered={data?.series.recovered ?? []}
104
+ window="30d"
105
+ />
106
+ </>
107
+ )}
108
+ </CardContent>
109
+ </Card>
110
+ );
111
+ }
112
+
113
+ export function BundleAnalyticsSummary({
114
+ bundleId,
115
+ }: BundleAnalyticsSummaryProps) {
116
+ const capability = useAnalyticsCapability();
117
+
118
+ if (capability.status !== "supported") {
119
+ return null;
120
+ }
121
+
122
+ return <SupportedBundleAnalyticsSummary bundleId={bundleId} />;
123
+ }