@hot-updater/console 1.1.3 → 1.1.5

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 (154) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/EventDetails-gbD_fCqc.js +1 -0
  3. package/.output/public/assets/{InsightsPageHeader-SYEOMClX.js → InsightsPageHeader-BRw8XkJW.js} +1 -1
  4. package/.output/public/assets/api-keys-D81VHHNM.js +1 -0
  5. package/.output/public/assets/api-keys-Dia6i8K1.js +1 -0
  6. package/.output/public/assets/badge-Bqf3UDee.js +1 -0
  7. package/.output/public/assets/circle-off-BsbZQtsF.js +1 -0
  8. package/.output/public/assets/field-GxF9SyUo.js +1 -0
  9. package/.output/public/assets/{index-C5S4DrO2.js → index-DrkdiRIn.js} +3 -3
  10. package/.output/public/assets/insights-C-5t8wh2.js +2 -0
  11. package/.output/public/assets/insights-D_ngQdiz.js +1 -0
  12. package/.output/public/assets/insights-KigRBDgC.js +42 -0
  13. package/.output/public/assets/insights-api-1ZjVu3Sb.js +1 -0
  14. package/.output/public/assets/insights-search-aT8BVCci.js +1 -0
  15. package/.output/public/assets/{insights-usage-rpc-DTL0_Rp8.js → insights-usage-rpc-DqTiXI1_.js} +1 -1
  16. package/.output/public/assets/insights_.distribution-Bzl6RFBR.js +2 -0
  17. package/.output/public/assets/insights_.distribution-Cthgttqb.js +1 -0
  18. package/.output/public/assets/insights_.distribution-D2c2v1bu.js +1 -0
  19. package/.output/public/assets/installations-2Nx8Htf-.js +1 -0
  20. package/.output/public/assets/{installations-we1IV0xp.js → installations-BskJ1xQr.js} +2 -2
  21. package/.output/public/assets/installations-K2iJ_r0h.js +1 -0
  22. package/.output/public/assets/{remote-config-uBgCaI-p.js → remote-config-D9X3NiwV.js} +4 -4
  23. package/.output/public/assets/{remote-config-DyTLPRKh.js → remote-config-DzOOiBIO.js} +1 -1
  24. package/.output/public/assets/remote-config-ikg7kak0.js +1 -0
  25. package/.output/public/assets/routes-9rb9P5Y7.js +2 -0
  26. package/.output/public/assets/routes-P7683vKQ.js +1 -0
  27. package/.output/public/assets/{select-CV8nkfsf.js → select-M3H2gxCc.js} +1 -1
  28. package/.output/public/assets/styles-CpiW_YdW.css +2 -0
  29. package/.output/public/assets/{switch-C8ycp9AH.js → switch-By_vfm8I.js} +1 -1
  30. package/.output/server/{__23tanstack-start-server-fn-resolver-Gb7cVpng.mjs → __23tanstack-start-server-fn-resolver-BTRoM3ZK.mjs} +40 -40
  31. package/.output/server/_libs/@base-ui/react+[...].mjs +77 -1
  32. package/.output/server/_libs/@tanstack/react-router+[...].mjs +1 -1
  33. package/.output/server/_libs/@tanstack/react-router-ssr-query+[...].mjs +2 -2
  34. package/.output/server/_libs/lucide-react.mjs +1 -1
  35. package/.output/server/_libs/next-themes.mjs +1 -1
  36. package/.output/server/_libs/react-redux+[...].mjs +1 -1
  37. package/.output/server/_libs/recharts+[...].mjs +1 -1
  38. package/.output/server/_libs/sonner.mjs +1 -1
  39. package/.output/server/_libs/tanstack__react-query.mjs +1 -1
  40. package/.output/server/_ssr/{BundleIdDisplay-DDiNTVT3.mjs → BundleIdDisplay-Cu_uxtLL.mjs} +3 -3
  41. package/.output/server/_ssr/{ConsoleFeatureUnavailable-DE65d293.mjs → ConsoleFeatureUnavailable-De7nGook.mjs} +7 -7
  42. package/.output/server/_ssr/{EventDetails-Bg1V3JsJ.mjs → EventDetails-B6qJU3Ho.mjs} +8 -8
  43. package/.output/server/_ssr/{HashValueDisplay-BDHvSsxT.mjs → HashValueDisplay-Dk7gXwSZ.mjs} +3 -3
  44. package/.output/server/_ssr/{InsightsPageHeader-Db-y_PRS.mjs → InsightsPageHeader-BqNItX24.mjs} +8 -8
  45. package/.output/server/_ssr/{NotFoundPage-DHgL7Uzm.mjs → NotFoundPage-O0Y4NQ5N.mjs} +3 -3
  46. package/.output/server/_ssr/{alert-dialog-MbJW0K9H.mjs → alert-dialog-G7g_XzVI.mjs} +2 -2
  47. package/.output/server/_ssr/{api-XOdKU3tw.mjs → api-BL3kC-5m.mjs} +3 -3
  48. package/.output/server/_ssr/{api-keys-v7wa2_rX.mjs → api-keys-CShII1tz.mjs} +2 -2
  49. package/.output/server/_ssr/{api-keys-BHJdcnez.mjs → api-keys-DdGuNGTn.mjs} +9 -8
  50. package/.output/server/_ssr/{api-keys-rpc-B389z2LY.mjs → api-keys-rpc-Dayd_DRM.mjs} +3 -3
  51. package/.output/server/_ssr/{api-rpc-T6dVPhPr.mjs → api-rpc-Cf625HwX.mjs} +6 -41
  52. package/.output/server/_ssr/{auth-rpc-CzPT9U2N.mjs → auth-rpc-Bg9zjpz5.mjs} +4 -4
  53. package/.output/server/_ssr/{auth.server-LmCV7BTA.mjs → auth.server-BQjMW0uK.mjs} +2 -2
  54. package/.output/server/_ssr/badge-DN8fj73I.mjs +108 -0
  55. package/.output/server/_ssr/{bundleActivity-M5gzBFNg.mjs → bundleActivity-0qFIcvqt.mjs} +1 -1
  56. package/.output/server/_ssr/{config.server-COvZCTA4.mjs → config.server-L7ll75x7.mjs} +4 -4
  57. package/.output/server/_ssr/{console-access-CRAcskRF.mjs → console-access-DeVju1PF.mjs} +2 -2
  58. package/.output/server/_ssr/{console-features-Oz0q20jJ.mjs → console-features-4xAGfZHw.mjs} +1 -1
  59. package/.output/server/_ssr/{console-features-api-qihYDNRo.mjs → console-features-api-BY65aaIY.mjs} +5 -5
  60. package/.output/server/_ssr/{console-features-rpc-r1KcSohP.mjs → console-features-rpc-DsTaZWvl.mjs} +3 -3
  61. package/.output/server/_ssr/{dialog-CPbM7GQc.mjs → dialog-CfpXr88f.mjs} +2 -2
  62. package/.output/server/_ssr/{downloadBundle-CZysjmNW.mjs → downloadBundle-94jPBGSO.mjs} +1 -1
  63. package/.output/server/_ssr/{empty-8scgvztU.mjs → empty-B2DK143D.mjs} +3 -3
  64. package/.output/server/_ssr/{failureReports-DKDhVsXA.mjs → failureReports-Bh22wois.mjs} +4 -4
  65. package/.output/server/_ssr/field-DvXxMlEZ.mjs +89 -0
  66. package/.output/server/_ssr/{getBundleChildren-Cv9iz1As.mjs → getBundleChildren-DfxB2K8D.mjs} +1 -1
  67. package/.output/server/_ssr/{input-group-BTB34g3v.mjs → input-group-DDXGLcxY.mjs} +2 -2
  68. package/.output/server/_ssr/{insights-CGpn8W5V.mjs → insights-BeTm0qQ2.mjs} +590 -360
  69. package/.output/server/_ssr/{insights-Btnkt5-Y.mjs → insights-BqC8tldt.mjs} +2 -2
  70. package/.output/server/_ssr/{insights-BulsRVnt.mjs → insights-CPSCgGN4.mjs} +5 -5
  71. package/.output/server/_ssr/{insights-api-Dg3D7Vkg.mjs → insights-api-BO700P1C.mjs} +5 -5
  72. package/.output/server/_ssr/{insights-errors-BjnFUyZj.mjs → insights-errors-Iy-lBaM7.mjs} +2 -2
  73. package/.output/server/_ssr/{insights-failures-xaa41Buf.mjs → insights-failures-BJN3RfLu.mjs} +3 -3
  74. package/.output/server/_ssr/{insights-failures-rpc-DdK55wRd.mjs → insights-failures-rpc-D6OK70Td.mjs} +12 -12
  75. package/.output/server/_ssr/{insights-recovery-BWQhiZaF.mjs → insights-recovery-BUuQdlA3.mjs} +1 -1
  76. package/.output/server/_ssr/{insights-recovery-rpc-D-dwlIPT.mjs → insights-recovery-rpc-Ir31GbY1.mjs} +6 -6
  77. package/.output/server/_ssr/{insights-rpc-Dn-jdkQK.mjs → insights-rpc-BSJ2KM34.mjs} +3 -3
  78. package/.output/server/_ssr/{insights-search-C8EWA0hu.mjs → insights-search-CkCpWvaA.mjs} +8 -7
  79. package/.output/server/_ssr/{insights-usage-BpBM0U0c.mjs → insights-usage-CHqvtehH.mjs} +1 -1
  80. package/.output/server/_ssr/{insights-usage-rpc-DIa-5vUO.mjs → insights-usage-rpc-CZ8sEK9v.mjs} +6 -6
  81. package/.output/server/_ssr/{insights-usage-rpc-BCulD17c.mjs → insights-usage-rpc-DQBuGtPL.mjs} +5 -5
  82. package/.output/server/_ssr/{insightsUsage-CzhVjsc8.mjs → insightsUsage-COUG-_-U.mjs} +3 -3
  83. package/.output/server/_ssr/{insights_.distribution-CE0ve_Rx.mjs → insights_.distribution-Coh7tQy7.mjs} +14 -13
  84. package/.output/server/_ssr/{insights_.distribution-D7ZzykQb.mjs → insights_.distribution-Dq_-XUJC.mjs} +2 -2
  85. package/.output/server/_ssr/{insights_.distribution-CKe3T4k1.mjs → insights_.distribution-Z6uZcuUP.mjs} +5 -5
  86. package/.output/server/_ssr/{installations-Bu4Sk4W6.mjs → installations-7BMDB-Tp.mjs} +4 -4
  87. package/.output/server/_ssr/{installations-CJHIYH0u.mjs → installations-CyPx28Vm.mjs} +2 -2
  88. package/.output/server/_ssr/{installations-CGMSRurj.mjs → installations-D8F2SIvA.mjs} +12 -11
  89. package/.output/server/_ssr/{release-adoption-005tQRpo.mjs → release-adoption-DuYJTGuX.mjs} +17 -7
  90. package/.output/server/_ssr/{release-adoption-rpc-DPMYNIlM.mjs → release-adoption-rpc-CreGclHX.mjs} +8 -8
  91. package/.output/server/_ssr/{releaseAdoption-CtOFkwli.mjs → releaseAdoption-B39cpvfo.mjs} +42 -26
  92. package/.output/server/_ssr/releaseReachability-bFsuElSS.mjs +39 -0
  93. package/.output/server/_ssr/{remote-config-DrC_3H7K.mjs → remote-config-Btt6_RTm.mjs} +2 -2
  94. package/.output/server/_ssr/{remote-config-Ca-Wv5sF.mjs → remote-config-BxGrsk5L.mjs} +18 -17
  95. package/.output/server/_ssr/{remote-config-BCW6vPOD.mjs → remote-config-CPY9yuqw.mjs} +3 -3
  96. package/.output/server/_ssr/{remote-config-rpc-CcGmTmvS.mjs → remote-config-rpc-Dd2ete-w.mjs} +3 -3
  97. package/.output/server/_ssr/{router-C5eI7aq7.mjs → router-Vd7IPVAC.mjs} +17 -17
  98. package/.output/server/_ssr/{routes-Bd3qlRny.mjs → routes-Cg0UP4L0.mjs} +17 -16
  99. package/.output/server/_ssr/{routes-DZoij0js.mjs → routes-DdZKGKrM.mjs} +17 -17
  100. package/.output/server/_ssr/{runtime.server-De0QVWc9.mjs → runtime.server-DsHqVqTr.mjs} +2 -2
  101. package/.output/server/_ssr/{select-usyW0pcU.mjs → select-DfawnwAT.mjs} +2 -2
  102. package/.output/server/_ssr/{server-Bp9nEe3Z.mjs → server-C36LNk44.mjs} +4 -4
  103. package/.output/server/_ssr/{sidebar-Bqu4emj1.mjs → sidebar-BNeODqno.mjs} +3 -3
  104. package/.output/server/_ssr/ssr.mjs +1 -1
  105. package/.output/server/_ssr/{switch-DJPcwvwD.mjs → switch-Cpbv90X-.mjs} +3 -3
  106. package/.output/server/_ssr/{table-DEzdHi1t.mjs → table-W02Ce9NY.mjs} +2 -2
  107. package/.output/server/_ssr/{tabs-HzTSngsE.mjs → tabs-CTxsEjmq.mjs} +3 -3
  108. package/.output/server/_ssr/updateFailures-z1IQy_4q.mjs +103 -0
  109. package/.output/server/{_tanstack-start-manifest_v-DTOQk1l4.mjs → _tanstack-start-manifest_v-_BY9hYTm.mjs} +51 -50
  110. package/.output/server/index.mjs +193 -186
  111. package/package.json +7 -7
  112. package/src/components/features/insights/AppUsage.tsx +15 -2
  113. package/src/components/features/insights/InsightsControls.tsx +264 -246
  114. package/src/components/features/insights/InsightsOverview.tsx +90 -68
  115. package/src/components/features/insights/InsightsScope.tsx +39 -0
  116. package/src/components/features/insights/ReleaseActivity.tsx +4 -4
  117. package/src/components/features/insights/ReleaseCombobox.tsx +196 -0
  118. package/src/components/features/insights/RollbackButton.tsx +4 -0
  119. package/src/components/features/insights/UpdateFailures.tsx +7 -1
  120. package/src/lib/insights-failures-rpc.ts +3 -1
  121. package/src/lib/insights-failures.ts +5 -2
  122. package/src/lib/insights-recovery.ts +5 -0
  123. package/src/lib/insights-search.ts +10 -14
  124. package/src/lib/release-adoption-api.ts +24 -32
  125. package/src/lib/release-adoption.ts +45 -8
  126. package/src/lib/server/failureReports.ts +1 -1
  127. package/src/lib/server/releaseAdoption.ts +75 -26
  128. package/src/lib/server/updateFailures.ts +131 -10
  129. package/src/routes/insights.tsx +56 -24
  130. package/src/routes/insights_.distribution.tsx +2 -1
  131. package/.output/public/assets/EventDetails-CSX2ayg9.js +0 -1
  132. package/.output/public/assets/api-keys-BEdLKiib.js +0 -1
  133. package/.output/public/assets/api-keys-TdTtKm2b.js +0 -1
  134. package/.output/public/assets/chevron-right-BnvHIJdZ.js +0 -1
  135. package/.output/public/assets/field-DBMkBfkD.js +0 -1
  136. package/.output/public/assets/insights-BMKRCIg1.js +0 -2
  137. package/.output/public/assets/insights-CPyViuyC.js +0 -42
  138. package/.output/public/assets/insights-D0PbX-Rm.js +0 -1
  139. package/.output/public/assets/insights-api-Dmd_LgT3.js +0 -1
  140. package/.output/public/assets/insights-search-DAa0f7Dr.js +0 -1
  141. package/.output/public/assets/insights_.distribution-BaOQlBV9.js +0 -1
  142. package/.output/public/assets/insights_.distribution-CxXMIEb5.js +0 -2
  143. package/.output/public/assets/insights_.distribution-EE5OqxyL.js +0 -1
  144. package/.output/public/assets/installations-BgISglUQ.js +0 -1
  145. package/.output/public/assets/installations-DQbgRkSi.js +0 -1
  146. package/.output/public/assets/remote-config-AQmdmbT8.js +0 -1
  147. package/.output/public/assets/routes-Br26jzt9.js +0 -1
  148. package/.output/public/assets/routes-CyirpG2O.js +0 -2
  149. package/.output/public/assets/styles-DTGEAJFv.css +0 -2
  150. package/.output/server/_ssr/field-DHrweW4l.mjs +0 -189
  151. package/.output/server/_ssr/updateFailures-H6pnnYK4.mjs +0 -40
  152. /package/.output/public/assets/{ConsoleFeatureUnavailable-B-iNWpuE.js → ConsoleFeatureUnavailable-C0KfsKWL.js} +0 -0
  153. /package/.output/public/assets/{table-DgGD67Fx.js → table-ek1DmRHq.js} +0 -0
  154. /package/.output/public/assets/{valueToPercent-C4C1WZVn.js → valueToPercent-B44vDssN.js} +0 -0
@@ -4,10 +4,11 @@ import { insightsPeriodEnd, recoveryWindows } from "./insights-recovery";
4
4
  import type { InsightsWindow } from "./insights-rpc";
5
5
  import type { HealthChart } from "./insights-search";
6
6
  import {
7
- DEFAULT_ADOPTION_RELEASES,
8
7
  type AdoptionRelease,
9
8
  type BundleEventType,
10
9
  type BundleEventsSeries,
10
+ defaultAdoptionReleases,
11
+ type HealthPlatform,
11
12
  MAX_ADOPTION_RELEASES,
12
13
  } from "./release-adoption";
13
14
  import {
@@ -39,7 +40,10 @@ export type ReleaseHealthState = {
39
40
  readonly durationMs: number;
40
41
  readonly intervalMs: number;
41
42
  };
42
- /** The newest bundle deployments, newest first; undefined while loading. */
43
+ /**
44
+ * The newest bundle deployments and the newest enabled ones, newest
45
+ * first; undefined while loading.
46
+ */
43
47
  readonly candidates: readonly AdoptionRelease[] | undefined;
44
48
  /** The compared bundles; undefined until they are known. */
45
49
  readonly releases: readonly ComparedBundle[] | undefined;
@@ -64,10 +68,10 @@ export function useReleaseHealth({
64
68
  focusReleaseId,
65
69
  chart,
66
70
  }: {
67
- readonly platform: "ios" | "android";
71
+ readonly platform: HealthPlatform;
68
72
  readonly channel: string;
69
73
  readonly window: InsightsWindow;
70
- /** Chosen bundles; undefined, the focus and its previous, or the newest. */
74
+ /** Chosen bundles; undefined, the focus and its previous, or the live ones. */
71
75
  readonly releaseIds: readonly string[] | undefined;
72
76
  readonly focusReleaseId: string | undefined;
73
77
  readonly chart: HealthChart;
@@ -83,23 +87,14 @@ export function useReleaseHealth({
83
87
  staleTime: 5 * 60_000,
84
88
  });
85
89
  const candidates = candidatesQuery.data;
86
- const focusIndex =
87
- focusReleaseId === undefined || candidates === undefined
88
- ? -1
89
- : candidates.findIndex((release) => release.releaseId === focusReleaseId);
90
- // A focus among the newest has its previous bundle there too, unless it is
91
- // the oldest of them.
90
+ // A focus and the bundle deployed before it on its platform.
92
91
  const focusQuery = useQuery({
93
92
  queryKey: ["insights", "adoption-release", scope, focusReleaseId, true],
94
93
  queryFn: () =>
95
94
  getAdoptionReleaseRpc({
96
95
  data: { ...scope, releaseId: focusReleaseId!, withPrevious: true },
97
96
  }),
98
- enabled:
99
- releaseIds === undefined &&
100
- focusReleaseId !== undefined &&
101
- candidates !== undefined &&
102
- (focusIndex === -1 || focusIndex === candidates.length - 1),
97
+ enabled: releaseIds === undefined && focusReleaseId !== undefined,
103
98
  staleTime: Number.POSITIVE_INFINITY,
104
99
  });
105
100
  const known = new Map<string, AdoptionRelease>();
@@ -110,28 +105,24 @@ export function useReleaseHealth({
110
105
  ...(candidates ?? []),
111
106
  ])
112
107
  known.set(release.releaseId, release);
113
- const newest = (list: readonly AdoptionRelease[]) =>
114
- list
115
- .slice(0, DEFAULT_ADOPTION_RELEASES)
116
- .map((release) => release.releaseId);
108
+ const live = (list: readonly AdoptionRelease[]) =>
109
+ defaultAdoptionReleases(list, platform);
117
110
  const chosen: readonly string[] | undefined =
118
111
  releaseIds !== undefined
119
112
  ? releaseIds.slice(0, MAX_ADOPTION_RELEASES)
120
113
  : candidates === undefined
121
114
  ? undefined
122
115
  : focusReleaseId === undefined
123
- ? newest(candidates)
124
- : focusIndex !== -1 && focusIndex < candidates.length - 1
125
- ? [focusReleaseId, candidates[focusIndex + 1]!.releaseId]
126
- : focusQuery.data === undefined
127
- ? undefined
128
- : focusQuery.data.release === null
129
- ? // A focus that is no bundle of this scope: the newest.
130
- newest(candidates)
131
- : [
132
- focusQuery.data.release.releaseId,
133
- focusQuery.data.previous?.releaseId,
134
- ].filter((id) => id !== undefined);
116
+ ? live(candidates)
117
+ : focusQuery.data === undefined
118
+ ? undefined
119
+ : focusQuery.data.release === null
120
+ ? // A focus that is no bundle of this scope: the live ones.
121
+ live(candidates)
122
+ : [
123
+ focusQuery.data.release.releaseId,
124
+ focusQuery.data.previous?.releaseId,
125
+ ].filter((id) => id !== undefined);
135
126
  // A chosen bundle the newest leave out, as from a copied link.
136
127
  const unknown = (chosen ?? []).filter(
137
128
  (releaseId) => !known.has(releaseId) && releaseId !== focusReleaseId,
@@ -159,7 +150,8 @@ export function useReleaseHealth({
159
150
  const seriesQueries = useQueries({
160
151
  queries: reads.map(({ release, type }) => {
161
152
  const data = {
162
- ...scope,
153
+ platform: release.platform,
154
+ channel,
163
155
  window,
164
156
  endMs,
165
157
  bundleId: release.bundleId,
@@ -6,28 +6,37 @@ import type { InsightsWindow } from "./insights-rpc";
6
6
  * Each is a read of its counts over the period, and four hues stay apart.
7
7
  */
8
8
  export const MAX_ADOPTION_RELEASES = 4;
9
- /** Bundles Release health compares when none are chosen: the newest two. */
9
+ /**
10
+ * Bundles Release health compares when none are chosen: the two newest live
11
+ * ones of one platform, or the newest live one of each.
12
+ */
10
13
  export const DEFAULT_ADOPTION_RELEASES = 2;
11
- /** Bundle deployments Release health offers to add. */
14
+ /** Bundle deployments of each platform Release health offers to add. */
12
15
  export const ADOPTION_CANDIDATES = 10;
13
16
  /** A crash rate at which Release health recommends rolling a bundle back. */
14
17
  export const CRASH_RATE_THRESHOLD = 0.05;
15
18
  /** Attempts below which a crash rate is too noisy to recommend anything. */
16
19
  export const CRASH_MIN_ATTEMPTS = 20;
17
20
 
21
+ /** Release health's platforms: one, or both side by side. */
22
+ export type HealthPlatform = "all" | "ios" | "android";
23
+
18
24
  /** A deployed bundle, as Release health labels and rolls it back. */
19
25
  export type AdoptionRelease = {
20
26
  readonly releaseId: string;
21
27
  readonly bundleId: string;
28
+ readonly platform: "ios" | "android";
22
29
  readonly deployedAtMs: number;
23
30
  readonly targetAppVersion: string | null;
24
31
  readonly enabled: boolean;
32
+ /** Whether a device can get it now: its scope's catalog still serves it. */
33
+ readonly live: boolean;
25
34
  readonly revision: number;
26
35
  };
27
36
 
28
- /** A channel and platform's newest bundle deployments. */
37
+ /** A channel's newest bundle deployments, of one platform or both. */
29
38
  export type AdoptionReleasesInput = {
30
- readonly platform: "ios" | "android";
39
+ readonly platform: HealthPlatform;
31
40
  readonly channel: string;
32
41
  };
33
42
 
@@ -51,7 +60,10 @@ export type AdoptionReleaseResult = {
51
60
  export type BundleEventType = "DOWNLOADED" | "LAUNCHED" | "RECOVERED";
52
61
 
53
62
  /** One bundle's reports of one type over the period that ends at `endMs`. */
54
- export type BundleEventsInput = AdoptionReleasesInput & {
63
+ export type BundleEventsInput = {
64
+ /** The bundle's own platform. */
65
+ readonly platform: "ios" | "android";
66
+ readonly channel: string;
55
67
  readonly window: InsightsWindow;
56
68
  /** The end of an hour; every bundle of one chart reads the same one. */
57
69
  readonly endMs: number;
@@ -90,7 +102,9 @@ export function readAdoptionReleasesInput(
90
102
  ): AdoptionReleasesInput {
91
103
  if (
92
104
  !input ||
93
- (input.platform !== "ios" && input.platform !== "android") ||
105
+ (input.platform !== "all" &&
106
+ input.platform !== "ios" &&
107
+ input.platform !== "android") ||
94
108
  !isText(input.channel)
95
109
  )
96
110
  invalid();
@@ -117,8 +131,9 @@ export function readAdoptionReleaseInput(
117
131
  export function readBundleEventsInput(
118
132
  input: BundleEventsInput,
119
133
  ): BundleEventsInput {
120
- const scope = readAdoptionReleasesInput(input);
134
+ const { platform, channel } = readAdoptionReleasesInput(input);
121
135
  if (
136
+ platform === "all" ||
122
137
  !Object.hasOwn(recoveryWindows, input.window) ||
123
138
  typeof input.endMs !== "number" ||
124
139
  !Number.isSafeInteger(input.endMs) ||
@@ -131,7 +146,8 @@ export function readBundleEventsInput(
131
146
  )
132
147
  invalid();
133
148
  return {
134
- ...scope,
149
+ platform: platform as BundleEventsInput["platform"],
150
+ channel,
135
151
  window: input.window,
136
152
  endMs: input.endMs,
137
153
  bundleId: input.bundleId,
@@ -149,6 +165,27 @@ export const crashRateOf = (launched: number, recovered: number) => {
149
165
  return { attempts, rate: attempts === 0 ? 0 : recovered / attempts };
150
166
  };
151
167
 
168
+ /**
169
+ * The bundles Release health compares when none are chosen, from the
170
+ * candidates newest first: the two newest live bundles of one platform, or
171
+ * the newest live one of each; without any live one, the newest two.
172
+ */
173
+ export const defaultAdoptionReleases = (
174
+ candidates: readonly AdoptionRelease[],
175
+ platform: HealthPlatform,
176
+ ): readonly string[] => {
177
+ const live = (
178
+ platform === "all" ? (["ios", "android"] as const) : [platform]
179
+ ).flatMap((each) =>
180
+ candidates
181
+ .filter((release) => release.platform === each && release.live)
182
+ .slice(0, platform === "all" ? 1 : DEFAULT_ADOPTION_RELEASES),
183
+ );
184
+ return (live.length > 0 ? live : candidates)
185
+ .slice(0, DEFAULT_ADOPTION_RELEASES)
186
+ .map((release) => release.releaseId);
187
+ };
188
+
152
189
  /** Whether a bundle's crashes call for rolling it back. */
153
190
  export const recommendsRollback = (
154
191
  release: Pick<AdoptionRelease, "enabled">,
@@ -36,7 +36,7 @@ export async function listFailureReports(
36
36
  ...page.data.filter(
37
37
  (event) =>
38
38
  event.type === "UPDATE_FAILED" &&
39
- event.platform === input.platform &&
39
+ (input.platform === "all" || event.platform === input.platform) &&
40
40
  event.channel === input.channel &&
41
41
  (input.releaseId === undefined ||
42
42
  (event.failure?.stage !== "check" &&
@@ -8,6 +8,7 @@ import { insightsPeriodEnd, recoveryWindows } from "../insights-recovery";
8
8
  import {
9
9
  ADOPTION_CANDIDATES,
10
10
  type AdoptionRelease,
11
+ type HealthPlatform,
11
12
  type AdoptionReleaseInput,
12
13
  type AdoptionReleaseResult,
13
14
  type AdoptionReleasesInput,
@@ -17,31 +18,55 @@ import {
17
18
  readAdoptionReleasesInput,
18
19
  readBundleEventsInput,
19
20
  } from "../release-adoption";
21
+ import { addReleaseReachability } from "./releaseReachability";
20
22
 
21
23
  type ReleaseReads = Pick<
22
24
  HotUpdaterCoreApi,
23
- "findChannelByName" | "getRelease" | "listReleases"
25
+ "findChannelByName" | "getRelease" | "getReleaseCatalogRow" | "listReleases"
24
26
  >;
25
27
 
26
28
  /** Rows read past a focused release to find the bundle before it. */
27
29
  const PREVIOUS_ROWS = 4;
28
30
 
29
- const labelOf = (release: ReleaseRow): AdoptionRelease => ({
30
- releaseId: release.id,
31
- bundleId: release.bundle_id!,
32
- deployedAtMs: release.created_at_ms,
33
- targetAppVersion: release.target_app_version,
34
- enabled: release.enabled,
35
- revision: release.revision,
36
- });
31
+ const platformsOf = (platform: HealthPlatform) =>
32
+ platform === "all" ? (["ios", "android"] as const) : [platform];
37
33
 
38
- // A release without a bundle has nothing to apply.
39
- const bundles = (releases: readonly ReleaseRow[]) =>
40
- releases.filter((release) => release.bundle_id).map(labelOf);
34
+ /**
35
+ * Labels the releases that have a bundle, a release without one having
36
+ * nothing to apply. A bundle is live when its scope's catalog still serves
37
+ * it: one catalog read per scope of the enabled ones.
38
+ */
39
+ const labelled = async (
40
+ core: Pick<ReleaseReads, "getReleaseCatalogRow">,
41
+ releases: readonly ReleaseRow[],
42
+ ): Promise<AdoptionRelease[]> => {
43
+ const bundles = releases.filter((release) => release.bundle_id);
44
+ const live = new Set(
45
+ (
46
+ await addReleaseReachability(
47
+ core,
48
+ bundles.filter((release) => release.enabled),
49
+ )
50
+ )
51
+ .filter((release) => !release.currentlyUnreachable)
52
+ .map((release) => release.id),
53
+ );
54
+ return bundles.map((release) => ({
55
+ releaseId: release.id,
56
+ bundleId: release.bundle_id!,
57
+ platform: release.platform,
58
+ deployedAtMs: release.created_at_ms,
59
+ targetAppVersion: release.target_app_version,
60
+ enabled: release.enabled,
61
+ live: live.has(release.id),
62
+ revision: release.revision,
63
+ }));
64
+ };
41
65
 
42
66
  /**
43
- * The newest bundle deployments of a channel and platform, newest first: one
44
- * read of the channel and one of its newest releases.
67
+ * A channel's newest bundle deployments, of one platform or both, newest
68
+ * first: per platform, its newest releases and its newest enabled ones, so
69
+ * a live bundle older than the newest still shows.
45
70
  */
46
71
  export async function listAdoptionReleases(
47
72
  core: ReleaseReads,
@@ -50,18 +75,37 @@ export async function listAdoptionReleases(
50
75
  const { platform, channel } = readAdoptionReleasesInput(input);
51
76
  const row = await core.findChannelByName(channel);
52
77
  if (row === null) return [];
53
- return bundles(
54
- await core.listReleases({
55
- filter: { kind: "channelPlatform", channelId: row.id, platform },
56
- order: "desc",
57
- limit: ADOPTION_CANDIDATES,
58
- }),
78
+ const lists = await Promise.all(
79
+ platformsOf(platform).flatMap((each) =>
80
+ [{}, { enabled: true }].map((enabled) =>
81
+ core.listReleases({
82
+ filter: {
83
+ kind: "channelPlatform",
84
+ channelId: row.id,
85
+ platform: each,
86
+ ...enabled,
87
+ },
88
+ order: "desc",
89
+ limit: ADOPTION_CANDIDATES,
90
+ }),
91
+ ),
92
+ ),
93
+ );
94
+ const releases = new Map(
95
+ lists.flat().map((release) => [release.id, release]),
96
+ );
97
+ return labelled(
98
+ core,
99
+ [...releases.values()].sort(
100
+ (left, right) => right.created_at_ms - left.created_at_ms,
101
+ ),
59
102
  );
60
103
  }
61
104
 
62
105
  /**
63
- * One bundle deployment of the channel and platform, for a focus the newest
64
- * ones leave out, and with `withPrevious` the bundle deployed before it.
106
+ * One bundle deployment of the channel, for a focus the newest ones leave
107
+ * out, and with `withPrevious` the bundle deployed before it on its
108
+ * platform.
65
109
  */
66
110
  export async function getAdoptionRelease(
67
111
  core: ReleaseReads,
@@ -77,19 +121,24 @@ export async function getAdoptionRelease(
77
121
  row === null ||
78
122
  release === null ||
79
123
  !release.bundle_id ||
80
- release.platform !== platform ||
124
+ (platform !== "all" && release.platform !== platform) ||
81
125
  release.channel_id !== row.id
82
126
  )
83
127
  return withPrevious ? { release: null, previous: null } : { release: null };
84
- if (!withPrevious) return { release: labelOf(release) };
128
+ if (!withPrevious) return { release: (await labelled(core, [release]))[0]! };
85
129
  // Newest first, `after` a release lists older ones.
86
130
  const older = await core.listReleases({
87
- filter: { kind: "channelPlatform", channelId: row.id, platform },
131
+ filter: {
132
+ kind: "channelPlatform",
133
+ channelId: row.id,
134
+ platform: release.platform,
135
+ },
88
136
  order: "desc",
89
137
  after: release.id,
90
138
  limit: PREVIOUS_ROWS,
91
139
  });
92
- return { release: labelOf(release), previous: bundles(older)[0] ?? null };
140
+ const [label, previous] = await labelled(core, [release, ...older]);
141
+ return { release: label!, previous: previous ?? null };
93
142
  }
94
143
 
95
144
  /**
@@ -1,4 +1,8 @@
1
- import type { InsightsReads } from "@hot-updater/server/plugins";
1
+ import type { HotUpdaterCoreApi } from "@hot-updater/plugin-core";
2
+ import type {
3
+ InsightsReads,
4
+ InsightsUpdateFailures,
5
+ } from "@hot-updater/server/plugins";
2
6
 
3
7
  import {
4
8
  checkFailureRate,
@@ -9,11 +13,111 @@ import {
9
13
  } from "../insights-failures";
10
14
  import { insightsPeriodEnd, recoveryWindows } from "../insights-recovery";
11
15
 
12
- /** A release's or a channel's update failures over a period that ends with the current hour. */
16
+ const total = (
17
+ reports: readonly InsightsUpdateFailures[],
18
+ count: (report: InsightsUpdateFailures) => number,
19
+ ) => reports.reduce((sum, report) => sum + count(report), 0);
20
+
21
+ /** Counts summed by key, most first, then in key order. */
22
+ const summed = <T extends { readonly events: number }>(
23
+ rows: readonly T[],
24
+ keyOf: (row: T) => string,
25
+ merge: (first: T, rows: readonly T[]) => T,
26
+ ): T[] => {
27
+ const groups = new Map<string, T[]>();
28
+ for (const row of rows) {
29
+ const key = keyOf(row);
30
+ groups.set(key, [...(groups.get(key) ?? []), row]);
31
+ }
32
+ return [...groups]
33
+ .map(([key, group]) => ({ key, row: merge(group[0]!, group) }))
34
+ .sort(
35
+ (left, right) =>
36
+ right.row.events - left.row.events ||
37
+ (left.key < right.key ? -1 : left.key > right.key ? 1 : 0),
38
+ )
39
+ .map(({ row }) => row);
40
+ };
41
+
42
+ /**
43
+ * Both platforms' failures as one report. An installation runs on one
44
+ * platform, so its counts and installation estimates add up; the period is
45
+ * complete only where both are.
46
+ */
47
+ export function mergeUpdateFailures(
48
+ reports: readonly InsightsUpdateFailures[],
49
+ ): InsightsUpdateFailures {
50
+ if (reports.length === 1) return reports[0]!;
51
+ const sinceMs = reports.map(({ coverage }) => coverage.sinceMs);
52
+ const latestSince = sinceMs.includes(null)
53
+ ? null
54
+ : Math.max(...(sinceMs as number[]));
55
+ const checks = reports.flatMap((report) =>
56
+ report.checks ? [report.checks] : [],
57
+ );
58
+ const breakdowns = reports.flatMap((report) => report.breakdown ?? []);
59
+ return {
60
+ coverage:
61
+ latestSince !== null &&
62
+ reports.every(({ coverage }) => coverage.kind === "complete")
63
+ ? { kind: "complete", sinceMs: latestSince }
64
+ : { kind: "partial", sinceMs: latestSince },
65
+ measuredAtMs: Math.min(...reports.map((report) => report.measuredAtMs)),
66
+ failedUpdates: total(reports, (report) => report.failedUpdates),
67
+ failedInstallations: total(reports, (report) => report.failedInstallations),
68
+ downloads: total(reports, (report) => report.downloads),
69
+ patchDownloads: total(reports, (report) => report.patchDownloads),
70
+ patchFallbacks: total(reports, (report) => report.patchFallbacks),
71
+ ...(checks.length === 0
72
+ ? {}
73
+ : {
74
+ checks: {
75
+ failures: checks.reduce((sum, row) => sum + row.failures, 0),
76
+ failedInstallations: checks.reduce(
77
+ (sum, row) => sum + row.failedInstallations,
78
+ 0,
79
+ ),
80
+ activeInstallations: checks.reduce(
81
+ (sum, row) => sum + row.activeInstallations,
82
+ 0,
83
+ ),
84
+ },
85
+ }),
86
+ ...(reports.some((report) => report.breakdown)
87
+ ? {
88
+ breakdown: summed(
89
+ breakdowns,
90
+ ({ stage, reason }) => JSON.stringify([stage, reason]),
91
+ (first, group) => ({
92
+ stage: first.stage,
93
+ reason: first.reason,
94
+ events: group.reduce((sum, row) => sum + row.events, 0),
95
+ details: summed(
96
+ group.flatMap((row) => row.details),
97
+ ({ resource, httpStatus, originCode, transport }) =>
98
+ JSON.stringify([resource, httpStatus, originCode, transport]),
99
+ (detail, details) => ({
100
+ ...detail,
101
+ events: details.reduce((sum, row) => sum + row.events, 0),
102
+ }),
103
+ ),
104
+ }),
105
+ ),
106
+ }
107
+ : {}),
108
+ };
109
+ }
110
+
111
+ /**
112
+ * A release's or a channel's update failures over a period that ends with
113
+ * the current hour. Both platforms read each and merge, except a release's,
114
+ * which reads only the platform it was deployed to.
115
+ */
13
116
  export async function getUpdateFailuresReport(
14
117
  reads: Pick<InsightsReads, "getUpdateFailures">,
15
118
  input: UpdateFailuresInput,
16
119
  now = Date.now(),
120
+ releases?: Pick<HotUpdaterCoreApi, "getRelease">,
17
121
  ): Promise<UpdateFailuresReport> {
18
122
  readUpdateFailuresInput(input);
19
123
  const end = insightsPeriodEnd(now);
@@ -25,10 +129,32 @@ export async function getUpdateFailuresReport(
25
129
  end,
26
130
  };
27
131
  const scope = {
28
- platform: input.platform,
29
132
  channel: input.channel,
30
133
  ...(input.releaseId === undefined ? {} : { releaseId: input.releaseId }),
31
134
  };
135
+ const release =
136
+ input.platform === "all" && input.releaseId !== undefined && releases
137
+ ? await releases.getRelease(input.releaseId)
138
+ : null;
139
+ const platforms =
140
+ input.platform !== "all"
141
+ ? [input.platform]
142
+ : release
143
+ ? [release.platform]
144
+ : (["ios", "android"] as const);
145
+ // Both platforms: one read of each, merged.
146
+ const read = async (range: typeof timeRange) =>
147
+ mergeUpdateFailures(
148
+ await Promise.all(
149
+ platforms.map((platform) =>
150
+ reads.getUpdateFailures({
151
+ ...scope,
152
+ platform,
153
+ ...(range === undefined ? {} : { timeRange: range }),
154
+ }),
155
+ ),
156
+ ),
157
+ );
32
158
  const previousRange =
33
159
  timeRange && timeRange.start >= timeRange.end - timeRange.start
34
160
  ? {
@@ -37,13 +163,8 @@ export async function getUpdateFailuresReport(
37
163
  }
38
164
  : undefined;
39
165
  const [failures, previous] = await Promise.all([
40
- reads.getUpdateFailures({
41
- ...scope,
42
- ...(timeRange === undefined ? {} : { timeRange }),
43
- }),
44
- previousRange === undefined
45
- ? undefined
46
- : reads.getUpdateFailures({ ...scope, timeRange: previousRange }),
166
+ read(timeRange),
167
+ previousRange === undefined ? undefined : read(previousRange),
47
168
  ]);
48
169
  return {
49
170
  ...failures,