@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,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,24 @@
1
+ import { createContext, useContext, type ReactNode } from "react";
2
+
3
+ import type { AnalyticsCapabilityState } from "@/lib/analytics-api";
4
+
5
+ const AnalyticsCapabilityContext = createContext<AnalyticsCapabilityState>({
6
+ status: "unresolved",
7
+ });
8
+
9
+ export function AnalyticsCapabilityProvider({
10
+ children,
11
+ value,
12
+ }: {
13
+ readonly children: ReactNode;
14
+ readonly value: AnalyticsCapabilityState;
15
+ }) {
16
+ return (
17
+ <AnalyticsCapabilityContext.Provider value={value}>
18
+ {children}
19
+ </AnalyticsCapabilityContext.Provider>
20
+ );
21
+ }
22
+
23
+ export const useAnalyticsCapability = (): AnalyticsCapabilityState =>
24
+ useContext(AnalyticsCapabilityContext);
@@ -0,0 +1,115 @@
1
+ import type { ActiveInstallationWindow } from "@hot-updater/server";
2
+ import { Search, X } from "lucide-react";
3
+ import { useState } from "react";
4
+
5
+ import { Button } from "@/components/ui/button";
6
+ import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
7
+ import {
8
+ InputGroup,
9
+ InputGroupAddon,
10
+ InputGroupButton,
11
+ InputGroupInput,
12
+ } from "@/components/ui/input-group";
13
+ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
14
+
15
+ const windows = [
16
+ { value: "24h", label: "24 hours", shortLabel: "24h" },
17
+ { value: "7d", label: "7 days", shortLabel: "7d" },
18
+ { value: "30d", label: "30 days", shortLabel: "30d" },
19
+ ] as const;
20
+
21
+ export function AnalyticsControls({
22
+ onInstallationSearch,
23
+ onWindowChange,
24
+ window,
25
+ }: {
26
+ readonly onInstallationSearch: (query: string) => void;
27
+ readonly onWindowChange: (window: ActiveInstallationWindow) => void;
28
+ readonly window: ActiveInstallationWindow;
29
+ }) {
30
+ const [draft, setDraft] = useState("");
31
+
32
+ return (
33
+ <section
34
+ aria-label="Analytics controls"
35
+ className="rounded-xl border bg-background px-4 py-3"
36
+ >
37
+ <form
38
+ aria-label="Filter analytics"
39
+ className="min-w-0"
40
+ onSubmit={(event) => {
41
+ event.preventDefault();
42
+ const query = draft.trim();
43
+ if (query) onInstallationSearch(query);
44
+ }}
45
+ role="search"
46
+ >
47
+ <FieldGroup className="gap-3 md:grid md:grid-cols-[auto_minmax(18rem,1fr)] md:items-end">
48
+ <Field className="min-w-0">
49
+ <FieldLabel>Reporting period</FieldLabel>
50
+ <ToggleGroup
51
+ aria-label="Reporting period"
52
+ className="w-full lg:w-fit"
53
+ onValueChange={(value) => {
54
+ if (value[0]) {
55
+ onWindowChange(value[0] as ActiveInstallationWindow);
56
+ }
57
+ }}
58
+ spacing={0}
59
+ size="lg"
60
+ value={[window]}
61
+ variant="outline"
62
+ >
63
+ {windows.map((item) => (
64
+ <ToggleGroupItem
65
+ aria-label={item.label}
66
+ className="flex-1 md:flex-none"
67
+ key={item.value}
68
+ value={item.value}
69
+ >
70
+ {item.shortLabel}
71
+ </ToggleGroupItem>
72
+ ))}
73
+ </ToggleGroup>
74
+ </Field>
75
+ <Field className="min-w-0">
76
+ <FieldLabel htmlFor="installation-history-search">
77
+ Installation history
78
+ </FieldLabel>
79
+ <div className="flex min-w-0 flex-col gap-2 sm:flex-row">
80
+ <InputGroup className="h-8 flex-1">
81
+ <InputGroupAddon>
82
+ <Search aria-hidden="true" />
83
+ </InputGroupAddon>
84
+ <InputGroupInput
85
+ aria-label="User or install ID"
86
+ id="installation-history-search"
87
+ maxLength={1024}
88
+ onChange={(event) => setDraft(event.target.value)}
89
+ placeholder="User ID or install ID"
90
+ type="search"
91
+ value={draft}
92
+ />
93
+ {draft && (
94
+ <InputGroupAddon align="inline-end">
95
+ <InputGroupButton
96
+ aria-label="Clear installation search"
97
+ onClick={() => setDraft("")}
98
+ size="icon-xs"
99
+ >
100
+ <X aria-hidden="true" />
101
+ </InputGroupButton>
102
+ </InputGroupAddon>
103
+ )}
104
+ </InputGroup>
105
+ <Button className="w-full sm:w-auto" size="lg" type="submit">
106
+ <Search aria-hidden="true" data-icon="inline-start" />
107
+ Search
108
+ </Button>
109
+ </div>
110
+ </Field>
111
+ </FieldGroup>
112
+ </form>
113
+ </section>
114
+ );
115
+ }
@@ -0,0 +1,21 @@
1
+ import { TriangleAlert } from "lucide-react";
2
+
3
+ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
4
+ import { getAnalyticsErrorCopy } from "@/lib/analytics-error";
5
+
6
+ export function AnalyticsErrorAlert({
7
+ error,
8
+ fallbackTitle,
9
+ }: {
10
+ readonly error: Error;
11
+ readonly fallbackTitle: string;
12
+ }) {
13
+ const copy = getAnalyticsErrorCopy(error, fallbackTitle);
14
+ return (
15
+ <Alert variant="destructive">
16
+ <TriangleAlert aria-hidden="true" />
17
+ <AlertTitle>{copy.title}</AlertTitle>
18
+ <AlertDescription>{copy.description}</AlertDescription>
19
+ </Alert>
20
+ );
21
+ }
@@ -0,0 +1,208 @@
1
+ import type { ActiveInstallationOverview } from "@hot-updater/server";
2
+ import type { ReactNode } from "react";
3
+
4
+ import {
5
+ Card,
6
+ CardContent,
7
+ CardDescription,
8
+ CardFooter,
9
+ CardHeader,
10
+ CardTitle,
11
+ } from "@/components/ui/card";
12
+ import { Skeleton } from "@/components/ui/skeleton";
13
+ import type { AnalyticsOverview as CatalogOverview } from "@/lib/analytics-overview";
14
+ import { cn } from "@/lib/utils";
15
+
16
+ import { ActivityChart } from "./ActivityChart";
17
+ import { AnalyticsErrorAlert } from "./AnalyticsErrorAlert";
18
+ import { BundleSelector } from "./BundleSelector";
19
+ import { UpdateOutcomes, type UpdateOutcomeState } from "./UpdateOutcomes";
20
+
21
+ type AnalyticsOverviewProps =
22
+ | { readonly status: "loading" }
23
+ | { readonly status: "error"; readonly error: Error }
24
+ | {
25
+ readonly status: "success";
26
+ readonly active: ActiveInstallationOverview;
27
+ readonly bundleId: string;
28
+ readonly bundles: readonly {
29
+ readonly bundleId: string;
30
+ readonly description: string;
31
+ }[];
32
+ readonly catalog: CatalogOverview;
33
+ readonly onBundleChange: (bundleId: string) => void;
34
+ readonly outcomes: UpdateOutcomeState;
35
+ };
36
+
37
+ const asOfFormatter = new Intl.DateTimeFormat("en", {
38
+ dateStyle: "medium",
39
+ timeStyle: "short",
40
+ timeZone: "UTC",
41
+ });
42
+
43
+ const activityWindowCopy = {
44
+ "24h": {
45
+ label: "Daily active installations",
46
+ period: "last 24 hours",
47
+ },
48
+ "7d": {
49
+ label: "Weekly active installations",
50
+ period: "last 7 days",
51
+ },
52
+ "30d": {
53
+ label: "Monthly active installations",
54
+ period: "last 30 days",
55
+ },
56
+ } as const;
57
+
58
+ function LoadingCard({
59
+ children,
60
+ className,
61
+ label,
62
+ }: {
63
+ readonly children: ReactNode;
64
+ readonly className?: string;
65
+ readonly label: string;
66
+ }) {
67
+ return (
68
+ <Card aria-label={label} className={cn("min-w-0", className)}>
69
+ <CardHeader>
70
+ <Skeleton className="h-4 w-32" />
71
+ <Skeleton className="h-3 w-4/5" />
72
+ </CardHeader>
73
+ <CardContent>{children}</CardContent>
74
+ </Card>
75
+ );
76
+ }
77
+
78
+ export function AnalyticsOverview(props: AnalyticsOverviewProps) {
79
+ if (props.status === "loading") {
80
+ return (
81
+ <div
82
+ aria-label="Loading reporting analytics"
83
+ className="flex min-w-0 flex-col gap-8"
84
+ >
85
+ <LoadingCard label="Loading installation activity">
86
+ <Skeleton className="h-10 w-24" />
87
+ <Skeleton className="mt-5 h-64 w-full" />
88
+ <div className="mt-5 grid gap-4 border-t pt-5 sm:grid-cols-3">
89
+ <Skeleton className="h-10 w-full" />
90
+ <Skeleton className="h-10 w-full" />
91
+ <Skeleton className="h-10 w-full" />
92
+ </div>
93
+ </LoadingCard>
94
+ <LoadingCard label="Loading bundle detail">
95
+ <div className="flex flex-col gap-4">
96
+ <Skeleton className="h-10 w-full max-w-md" />
97
+ <div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
98
+ <Skeleton className="h-14 w-full" />
99
+ <Skeleton className="h-14 w-full" />
100
+ <Skeleton className="h-14 w-full" />
101
+ <Skeleton className="h-14 w-full" />
102
+ </div>
103
+ <Skeleton className="h-32 w-full" />
104
+ </div>
105
+ </LoadingCard>
106
+ </div>
107
+ );
108
+ }
109
+
110
+ if (props.status === "error") {
111
+ return (
112
+ <AnalyticsErrorAlert
113
+ error={props.error}
114
+ fallbackTitle="Reporting analytics unavailable"
115
+ />
116
+ );
117
+ }
118
+
119
+ const { active, bundleId, bundles, catalog, onBundleChange, outcomes } =
120
+ props;
121
+ const activityCopy = activityWindowCopy[active.window];
122
+ const selectedBundleId =
123
+ outcomes.status === "idle" ? null : outcomes.bundleId;
124
+ const latestBundleInstallations =
125
+ active.bundles.find(({ bundleId }) => bundleId === selectedBundleId)
126
+ ?.installations ?? 0;
127
+ const configuredPercentage =
128
+ catalog.configuredRollouts.find(
129
+ ({ bundleId }) => bundleId === selectedBundleId,
130
+ )?.configuredPercentage ?? null;
131
+
132
+ return (
133
+ <div className="flex min-w-0 flex-col gap-8">
134
+ <section aria-label="Installation activity">
135
+ <Card className="min-w-0 overflow-hidden shadow-sm">
136
+ <CardHeader className="pb-4">
137
+ <CardTitle className="text-sm font-medium">
138
+ <h2>{activityCopy.label}</h2>
139
+ </CardTitle>
140
+ <CardDescription>
141
+ Unique app installations that reported an update status in the{" "}
142
+ {activityCopy.period}.
143
+ </CardDescription>
144
+ </CardHeader>
145
+ <CardContent className="flex flex-col gap-5">
146
+ <div className="flex flex-col gap-1">
147
+ <p className="text-xs font-medium text-muted-foreground">
148
+ Active installations
149
+ </p>
150
+ <div className="flex items-end gap-2">
151
+ <span className="text-4xl font-semibold tracking-tight tabular-nums">
152
+ {active.activeInstallations.toLocaleString()}
153
+ </span>
154
+ <span className="pb-1 text-xs text-muted-foreground">
155
+ unique in {activityCopy.period}
156
+ </span>
157
+ </div>
158
+ </div>
159
+ <div className="border-t pt-5">
160
+ <ActivityChart series={active.series} window={active.window} />
161
+ </div>
162
+ </CardContent>
163
+ <CardFooter className="border-t bg-muted/15 p-0">
164
+ <dl className="grid w-full sm:grid-cols-3 sm:divide-x sm:divide-border/70">
165
+ <div className="flex flex-col gap-1 px-5 py-4">
166
+ <dt className="text-xs text-muted-foreground">
167
+ Reported bundles
168
+ </dt>
169
+ <dd className="text-lg font-semibold tabular-nums">
170
+ {active.bundles.length.toLocaleString()}
171
+ </dd>
172
+ </div>
173
+ <div className="flex flex-col gap-1 border-t px-5 py-4 sm:border-t-0">
174
+ <dt className="text-xs text-muted-foreground">
175
+ Reporting window
176
+ </dt>
177
+ <dd className="text-sm font-medium">{activityCopy.period}</dd>
178
+ </div>
179
+ <div className="flex flex-col gap-1 border-t px-5 py-4 sm:border-t-0">
180
+ <dt className="text-xs text-muted-foreground">As of</dt>
181
+ <dd className="text-xs font-medium tabular-nums">
182
+ {asOfFormatter.format(new Date(active.asOfMs))} UTC
183
+ </dd>
184
+ </div>
185
+ </dl>
186
+ </CardFooter>
187
+ </Card>
188
+ </section>
189
+
190
+ <section aria-labelledby="bundle-detail-heading">
191
+ <UpdateOutcomes
192
+ bundleSelector={
193
+ <BundleSelector
194
+ bundleId={bundleId}
195
+ bundles={bundles}
196
+ onBundleChange={onBundleChange}
197
+ />
198
+ }
199
+ configuredPercentage={configuredPercentage}
200
+ latestBundleInstallations={latestBundleInstallations}
201
+ reportingInstallations={active.activeInstallations}
202
+ state={outcomes}
203
+ window={active.window}
204
+ />
205
+ </section>
206
+ </div>
207
+ );
208
+ }
@@ -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 { AnalyticsOverview } from "@/lib/analytics-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: AnalyticsOverview;
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
+ }