@hot-updater/console 1.1.2 → 1.1.4

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-B2gMrGs6.mjs → __23tanstack-start-server-fn-resolver-KpQ5Z_24.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-BIXUWOM_.mjs → BundleIdDisplay-CjSnY65J.mjs} +3 -3
  41. package/.output/server/_ssr/{ConsoleFeatureUnavailable-B5BX1XJQ.mjs → ConsoleFeatureUnavailable-CHxn9f-w.mjs} +7 -7
  42. package/.output/server/_ssr/{EventDetails-C0bZVuOS.mjs → EventDetails-l6dF54zp.mjs} +8 -8
  43. package/.output/server/_ssr/{HashValueDisplay-DtWes-Gr.mjs → HashValueDisplay-Zs7ABoOi.mjs} +3 -3
  44. package/.output/server/_ssr/{InsightsPageHeader-CLP-sCpY.mjs → InsightsPageHeader-ZlAlDxQy.mjs} +8 -8
  45. package/.output/server/_ssr/{NotFoundPage-BVQx08zn.mjs → NotFoundPage-u28ndvp1.mjs} +3 -3
  46. package/.output/server/_ssr/{alert-dialog-BlEXzHma.mjs → alert-dialog-B8AqlJwo.mjs} +2 -2
  47. package/.output/server/_ssr/{api-CVve4Y9C.mjs → api-C_0B7NXM.mjs} +3 -3
  48. package/.output/server/_ssr/{api-keys-Dknq3e2R.mjs → api-keys-CLH38u9B.mjs} +2 -2
  49. package/.output/server/_ssr/{api-keys-2TVuUP_S.mjs → api-keys-pRNvn2-9.mjs} +9 -8
  50. package/.output/server/_ssr/{api-keys-rpc-D6mjj6ux.mjs → api-keys-rpc-wecWs0nA.mjs} +3 -3
  51. package/.output/server/_ssr/{api-rpc-C2zK3DoM.mjs → api-rpc-DWN8eOHS.mjs} +6 -41
  52. package/.output/server/_ssr/{auth-rpc-Dss6nG8G.mjs → auth-rpc-CLRArbIl.mjs} +4 -4
  53. package/.output/server/_ssr/{auth.server-DLi4kbQi.mjs → auth.server-DR9zc0EU.mjs} +2 -2
  54. package/.output/server/_ssr/badge-BpT7r-ng.mjs +108 -0
  55. package/.output/server/_ssr/{bundleActivity-M5gzBFNg.mjs → bundleActivity-0qFIcvqt.mjs} +1 -1
  56. package/.output/server/_ssr/{config.server-B_oTMFxL.mjs → config.server-DTdsG47T.mjs} +4 -4
  57. package/.output/server/_ssr/{console-access-UB-4rDiA.mjs → console-access-aUpQs49I.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-DJV790QE.mjs → console-features-api-DSSgCV3v.mjs} +5 -5
  60. package/.output/server/_ssr/{console-features-rpc-cai3k4Ac.mjs → console-features-rpc-D6kMlR2A.mjs} +3 -3
  61. package/.output/server/_ssr/{dialog-CdG-5FiE.mjs → dialog-B4ALf7NJ.mjs} +2 -2
  62. package/.output/server/_ssr/{downloadBundle-CZysjmNW.mjs → downloadBundle-94jPBGSO.mjs} +1 -1
  63. package/.output/server/_ssr/{empty-CtbzbPdQ.mjs → empty-Bh1FEeYF.mjs} +3 -3
  64. package/.output/server/_ssr/{failureReports-DKDhVsXA.mjs → failureReports-Bh22wois.mjs} +4 -4
  65. package/.output/server/_ssr/field-HKCaPH3e.mjs +89 -0
  66. package/.output/server/_ssr/{getBundleChildren-Cv9iz1As.mjs → getBundleChildren-DfxB2K8D.mjs} +1 -1
  67. package/.output/server/_ssr/{input-group-c65TGOPW.mjs → input-group-xm3LzDuq.mjs} +2 -2
  68. package/.output/server/_ssr/{insights-_IGtova5.mjs → insights-BKZpJ_K4.mjs} +590 -360
  69. package/.output/server/_ssr/{insights-DU_QAXWM.mjs → insights-CCyTACIw.mjs} +5 -5
  70. package/.output/server/_ssr/{insights-Bn2m3_41.mjs → insights-CNqWQLJR.mjs} +2 -2
  71. package/.output/server/_ssr/{insights-api-Ds7dvWu4.mjs → insights-api-DtRFz5X3.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-DdcX5E7T.mjs → insights-failures-rpc-DLZ91uOG.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-DOhy6APf.mjs → insights-recovery-rpc-BQHUhPi7.mjs} +6 -6
  77. package/.output/server/_ssr/{insights-rpc-US_gs2id.mjs → insights-rpc-C6Vukyn7.mjs} +3 -3
  78. package/.output/server/_ssr/{insights-search-C70xifQF.mjs → insights-search-CBYoxT7v.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-5DtQXLAY.mjs → insights-usage-rpc-BJ7a-xco.mjs} +6 -6
  81. package/.output/server/_ssr/{insights-usage-rpc-TTivW3zB.mjs → insights-usage-rpc-C-lNUIsD.mjs} +5 -5
  82. package/.output/server/_ssr/{insightsUsage-CzhVjsc8.mjs → insightsUsage-COUG-_-U.mjs} +3 -3
  83. package/.output/server/_ssr/{insights_.distribution-D5U5EzU-.mjs → insights_.distribution-CTwilMPm.mjs} +5 -5
  84. package/.output/server/_ssr/{insights_.distribution-BvA8x4Pc.mjs → insights_.distribution-DmLW8I8a.mjs} +2 -2
  85. package/.output/server/_ssr/{insights_.distribution-Wm6hKWPa.mjs → insights_.distribution-DztDyrBj.mjs} +14 -13
  86. package/.output/server/_ssr/{installations-BmuAKPlD.mjs → installations-B6MK5iA8.mjs} +2 -2
  87. package/.output/server/_ssr/{installations-D6X1wvQA.mjs → installations-Bct7ag5y.mjs} +4 -4
  88. package/.output/server/_ssr/{installations-rfEUgSuk.mjs → installations-_N4PtYFl.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-VPRjwQya.mjs → release-adoption-rpc-CHl7G_Zy.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-BW3yo4ZC.mjs → remote-config-10-obSZ0.mjs} +3 -3
  94. package/.output/server/_ssr/{remote-config-C6qyWeqT.mjs → remote-config-Dj1eTYi4.mjs} +2 -2
  95. package/.output/server/_ssr/{remote-config-BdOas6KI.mjs → remote-config-Mj05qVYZ.mjs} +18 -17
  96. package/.output/server/_ssr/{remote-config-rpc-DNjk69nN.mjs → remote-config-rpc-lteqDSLn.mjs} +3 -3
  97. package/.output/server/_ssr/{router-DqyQ8o8T.mjs → router-Dr4jjjhx.mjs} +17 -17
  98. package/.output/server/_ssr/{routes-1TWMRTCY.mjs → routes-B5KrCDwk.mjs} +17 -16
  99. package/.output/server/_ssr/{routes-DxfbhYOp.mjs → routes-BZbrY_qw.mjs} +17 -17
  100. package/.output/server/_ssr/{runtime.server-De0QVWc9.mjs → runtime.server-DsHqVqTr.mjs} +2 -2
  101. package/.output/server/_ssr/{select-CEyhDZtT.mjs → select-BtdGNSmp.mjs} +2 -2
  102. package/.output/server/_ssr/{server-BUe4_4dH.mjs → server-BxaHSwHh.mjs} +4 -4
  103. package/.output/server/_ssr/{sidebar-De1__izs.mjs → sidebar-CVsgosaD.mjs} +3 -3
  104. package/.output/server/_ssr/ssr.mjs +1 -1
  105. package/.output/server/_ssr/{switch-B6TmLbu-.mjs → switch-BmARgkUP.mjs} +3 -3
  106. package/.output/server/_ssr/{table-BEpbu0xs.mjs → table-M5P3YUfY.mjs} +2 -2
  107. package/.output/server/_ssr/{tabs-DP0GnDdN.mjs → tabs-CrmfOlJj.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 +204 -197
  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-0KdP_8LI.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
@@ -1,9 +1,17 @@
1
1
  import { Link } from "@tanstack/react-router";
2
- import { ListIcon, RotateCcw, RotateCw, TriangleAlert, X } from "lucide-react";
2
+ import {
3
+ CircleOff,
4
+ ListIcon,
5
+ RotateCcw,
6
+ RotateCw,
7
+ TriangleAlert,
8
+ X,
9
+ } from "lucide-react";
3
10
  import { useState } from "react";
4
11
 
5
12
  import { BundleIdDisplay } from "@/components/BundleIdDisplay";
6
13
  import { InfoTooltip } from "@/components/InfoTooltip";
14
+ import { PlatformIcon } from "@/components/PlatformIcon";
7
15
  import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
8
16
  import { Badge } from "@/components/ui/badge";
9
17
  import { Button, buttonVariants } from "@/components/ui/button";
@@ -21,14 +29,6 @@ import {
21
29
  EmptyHeader,
22
30
  EmptyTitle,
23
31
  } from "@/components/ui/empty";
24
- import {
25
- Select,
26
- SelectContent,
27
- SelectGroup,
28
- SelectItem,
29
- SelectTrigger,
30
- SelectValue,
31
- } from "@/components/ui/select";
32
32
  import { Skeleton } from "@/components/ui/skeleton";
33
33
  import {
34
34
  Table,
@@ -39,13 +39,12 @@ import {
39
39
  TableRow,
40
40
  } from "@/components/ui/table";
41
41
  import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
42
- import type { RecoveryInput } from "@/lib/insights-recovery";
42
+ import type { ReleaseHealthInput } from "@/lib/insights-recovery";
43
43
  import type { HealthChart } from "@/lib/insights-search";
44
44
  import {
45
45
  CRASH_MIN_ATTEMPTS,
46
46
  CRASH_RATE_THRESHOLD,
47
47
  crashRateOf,
48
- DEFAULT_ADOPTION_RELEASES,
49
48
  MAX_ADOPTION_RELEASES,
50
49
  recommendsRollback,
51
50
  } from "@/lib/release-adoption";
@@ -62,6 +61,8 @@ import {
62
61
  } from "./ComparedBundlesChart";
63
62
  import { InsightsErrorAlert } from "./InsightsErrorAlert";
64
63
  import { InsightsPeriodSelector } from "./InsightsPeriodSelector";
64
+ import { InsightsScope, platformLabel } from "./InsightsScope";
65
+ import { ReleaseCombobox } from "./ReleaseCombobox";
65
66
  import { RollbackButton } from "./RollbackButton";
66
67
 
67
68
  const HOUR = 3_600_000;
@@ -113,7 +114,7 @@ function LineSample({ dashed }: { readonly dashed?: boolean }) {
113
114
  }
114
115
 
115
116
  /**
116
- * Release health: how the newest bundles fare after their deployment, on
117
+ * Release health: how the live bundles fare after their deployment, on
117
118
  * one timeline, per interval. Adoption shows installations downloading each
118
119
  * bundle (dashed) and launching it (solid), so the gap is the restarts still
119
120
  * to come; Crashes shows them crashing back from it, and offers a rollback
@@ -126,22 +127,22 @@ export function InsightsOverview({
126
127
  health,
127
128
  onReleasesChange,
128
129
  onWindowChange,
130
+ onShowAllPlatforms,
129
131
  }: {
130
- readonly input: RecoveryInput;
132
+ readonly input: ReleaseHealthInput;
131
133
  readonly chart: HealthChart;
132
134
  readonly onChartChange: (chart: HealthChart) => void;
133
135
  readonly health: ReleaseHealthState;
134
136
  /** Chooses the bundles to compare; undefined returns to the default. */
135
137
  readonly onReleasesChange: (releaseIds?: readonly string[]) => void;
136
- readonly onWindowChange: (window: RecoveryInput["window"]) => void;
138
+ readonly onWindowChange: (window: ReleaseHealthInput["window"]) => void;
139
+ /** Widens the platform filter, offered when one platform has no bundles. */
140
+ readonly onShowAllPlatforms?: () => void;
137
141
  }) {
138
142
  const [active, setActive] = useState<string | null>(null);
139
143
  const { period, candidates, releases } = health;
140
144
  const ids = (releases ?? []).map(({ release }) => release.releaseId);
141
145
  const colorOf = useSeriesColors(ids);
142
- const addable = (candidates ?? []).filter(
143
- (candidate) => !ids.includes(candidate.releaseId),
144
- );
145
146
  const full = ids.length >= MAX_ADOPTION_RELEASES;
146
147
  const wider =
147
148
  period.durationMs < 7 * DAY
@@ -156,7 +157,7 @@ export function InsightsOverview({
156
157
  onClick={() => onReleasesChange(undefined)}
157
158
  >
158
159
  <RotateCcw aria-hidden="true" data-icon="inline-start" />
159
- Show the newest {DEFAULT_ADOPTION_RELEASES}
160
+ Show live bundles
160
161
  </Button>
161
162
  );
162
163
  // Adoption draws each bundle's launches solid and its downloads dashed;
@@ -241,14 +242,28 @@ export function InsightsOverview({
241
242
  return (
242
243
  <Empty className="min-h-64">
243
244
  <EmptyHeader>
244
- <EmptyTitle>No bundles deployed here yet</EmptyTitle>
245
+ <EmptyTitle>
246
+ No bundles deployed to {input.channel} ·{" "}
247
+ {platformLabel(input.platform)}
248
+ </EmptyTitle>
245
249
  <EmptyDescription>
246
- Release health follows the bundle deployments of the selected
247
- channel and health platform. Deploy one, or choose another in the
248
- filters.
250
+ Deploy one, or choose another channel or platform in the filters.
249
251
  </EmptyDescription>
250
252
  </EmptyHeader>
251
- {reset ? <EmptyContent>{reset}</EmptyContent> : null}
253
+ {reset || (input.platform !== "all" && onShowAllPlatforms) ? (
254
+ <EmptyContent className="flex-row flex-wrap justify-center">
255
+ {input.platform !== "all" && onShowAllPlatforms ? (
256
+ <Button
257
+ variant="outline"
258
+ size="sm"
259
+ onClick={onShowAllPlatforms}
260
+ >
261
+ Show all platforms
262
+ </Button>
263
+ ) : null}
264
+ {reset}
265
+ </EmptyContent>
266
+ ) : null}
252
267
  </Empty>
253
268
  );
254
269
  }
@@ -339,7 +354,14 @@ export function InsightsOverview({
339
354
  <Card className="min-w-0 overflow-hidden shadow-sm">
340
355
  <CardHeader className="flex flex-col gap-4 p-6">
341
356
  <div className="flex flex-wrap items-center justify-between gap-4">
342
- <CardTitle>Release health</CardTitle>
357
+ <div className="flex min-w-0 flex-col gap-1">
358
+ <CardTitle>Release health</CardTitle>
359
+ <InsightsScope
360
+ channel={input.channel}
361
+ platform={input.platform}
362
+ narrower={input.releaseId ? [`Release ${input.releaseId}`] : []}
363
+ />
364
+ </div>
343
365
  <div className="flex items-center gap-2">
344
366
  <InsightsPeriodSelector
345
367
  window={input.window}
@@ -419,6 +441,13 @@ export function InsightsOverview({
419
441
  rate={bundle.rate}
420
442
  crashes={bundle.crashes}
421
443
  attempts={bundle.attempts}
444
+ // The live bundles no longer include it once it is
445
+ // disabled: keep comparing the same ones.
446
+ onRolledBack={
447
+ health.isDefault
448
+ ? () => onReleasesChange(ids)
449
+ : undefined
450
+ }
422
451
  />
423
452
  </AlertDescription>
424
453
  </Alert>
@@ -500,11 +529,33 @@ export function InsightsOverview({
500
529
  )}
501
530
  </span>
502
531
  <span className="text-xs break-words text-muted-foreground">
532
+ <PlatformIcon
533
+ platform={release.platform}
534
+ className="mr-1 inline size-3 align-[-1px]"
535
+ />
536
+ <span className="sr-only">
537
+ {platformLabel(release.platform)}{" "}
538
+ </span>
503
539
  Deployed{" "}
504
540
  {formatDeployedAt(release.deployedAtMs)}
505
541
  {release.targetAppVersion
506
542
  ? ` · ${release.targetAppVersion}`
507
543
  : ""}
544
+ {release.enabled && !release.live ? (
545
+ <>
546
+ {" · "}
547
+ <span
548
+ className="inline-flex items-center gap-1 align-middle"
549
+ title="No device gets this bundle with the current delivery settings."
550
+ >
551
+ <CircleOff
552
+ aria-hidden="true"
553
+ className="size-3"
554
+ />
555
+ Unreachable
556
+ </span>
557
+ </>
558
+ ) : null}
508
559
  </span>
509
560
  </div>
510
561
  </div>
@@ -577,51 +628,22 @@ export function InsightsOverview({
577
628
  })}
578
629
  </TableBody>
579
630
  </Table>
580
- {addable.length > 0 ? (
581
- <Select
582
- items={Object.fromEntries(
583
- addable.map((candidate) => [
584
- candidate.releaseId,
585
- `${candidate.releaseId} · ${formatDeployedAt(candidate.deployedAtMs)}`,
586
- ]),
587
- )}
588
- value={null}
631
+ {(candidates ?? []).length > 0 ? (
632
+ <ReleaseCombobox
633
+ scope={{ channel: input.channel, platform: input.platform }}
634
+ candidates={candidates ?? []}
635
+ excluded={ids}
589
636
  disabled={full}
590
- onValueChange={(value) => {
591
- if (value !== null) onReleasesChange([...ids, value]);
592
- }}
593
- >
594
- <SelectTrigger
595
- aria-label="Add a bundle"
596
- className="min-h-11 w-full sm:min-h-9 sm:w-80"
597
- >
598
- <SelectValue
599
- placeholder={
600
- full
601
- ? `Up to ${MAX_ADOPTION_RELEASES} bundles`
602
- : "Add a bundle"
603
- }
604
- />
605
- </SelectTrigger>
606
- <SelectContent>
607
- <SelectGroup>
608
- {addable.map((candidate) => (
609
- <SelectItem
610
- key={candidate.releaseId}
611
- value={candidate.releaseId}
612
- >
613
- <span className="flex min-w-0 flex-col gap-0.5">
614
- <BundleIdDisplay bundleId={candidate.releaseId} />{" "}
615
- <span className="text-xs text-muted-foreground">
616
- Deployed{" "}
617
- {formatDeployedAt(candidate.deployedAtMs)}
618
- </span>
619
- </span>
620
- </SelectItem>
621
- ))}
622
- </SelectGroup>
623
- </SelectContent>
624
- </Select>
637
+ label="Add a bundle"
638
+ placeholder={
639
+ full
640
+ ? `Up to ${MAX_ADOPTION_RELEASES} bundles`
641
+ : "Add a bundle by ID or app version…"
642
+ }
643
+ onPick={(release) =>
644
+ onReleasesChange([...ids, release.releaseId])
645
+ }
646
+ />
625
647
  ) : null}
626
648
  </>
627
649
  ) : null}
@@ -0,0 +1,39 @@
1
+ import { Fragment } from "react";
2
+
3
+ import { PlatformIcon } from "@/components/PlatformIcon";
4
+ import type { HealthPlatform } from "@/lib/release-adoption";
5
+
6
+ export const platformLabel = (platform: HealthPlatform) =>
7
+ platform === "all" ? "All platforms" : platform === "ios" ? "iOS" : "Android";
8
+
9
+ /** What a report reads, as the filters set it: its channel, platform, and any narrower filter. */
10
+ export function InsightsScope({
11
+ channel,
12
+ platform,
13
+ narrower = [],
14
+ }: {
15
+ readonly channel: string;
16
+ readonly platform: HealthPlatform;
17
+ readonly narrower?: readonly string[];
18
+ }) {
19
+ return (
20
+ <p className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs text-muted-foreground">
21
+ <span className="max-w-full truncate font-medium text-foreground">
22
+ {channel}
23
+ </span>
24
+ <span aria-hidden="true">·</span>
25
+ <span className="inline-flex items-center gap-1">
26
+ {platform === "all" ? null : (
27
+ <PlatformIcon platform={platform} className="size-3" />
28
+ )}
29
+ {platformLabel(platform)}
30
+ </span>
31
+ {narrower.map((filter) => (
32
+ <Fragment key={filter}>
33
+ <span aria-hidden="true">·</span>
34
+ <span className="max-w-full truncate">{filter}</span>
35
+ </Fragment>
36
+ ))}
37
+ </p>
38
+ );
39
+ }
@@ -140,8 +140,8 @@ export function BundleMovementSummary({
140
140
  className="min-w-0 rounded-sm hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
141
141
  to="/insights"
142
142
  search={{
143
- healthPlatform: input.platform,
144
- healthChannel: input.channel,
143
+ platform: input.platform,
144
+ channel: input.channel,
145
145
  releaseId: input.releaseId,
146
146
  bundleWindow: adoptionWindow(input.releaseId),
147
147
  }}
@@ -241,8 +241,8 @@ export function BundleInsightsSummary({
241
241
  className={buttonVariants({ variant: "outline", size: "sm" })}
242
242
  to="/insights"
243
243
  search={{
244
- healthPlatform: input.platform,
245
- healthChannel: input.channel,
244
+ platform: input.platform,
245
+ channel: input.channel,
246
246
  releaseId: input.releaseId,
247
247
  bundleWindow: adoptionWindow(input.releaseId),
248
248
  healthChart: "adoption",
@@ -0,0 +1,196 @@
1
+ import { isUUIDv7 } from "@hot-updater/protocol";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import { CircleOff } from "lucide-react";
4
+ import { type ReactNode, useState } from "react";
5
+
6
+ import { BundleIdDisplay } from "@/components/BundleIdDisplay";
7
+ import { PlatformIcon } from "@/components/PlatformIcon";
8
+ import { Badge } from "@/components/ui/badge";
9
+ import {
10
+ Combobox,
11
+ ComboboxCollection,
12
+ ComboboxContent,
13
+ ComboboxEmpty,
14
+ ComboboxGroup,
15
+ ComboboxInput,
16
+ ComboboxItem,
17
+ ComboboxLabel,
18
+ ComboboxList,
19
+ } from "@/components/ui/combobox";
20
+ import type {
21
+ AdoptionRelease,
22
+ AdoptionReleasesInput,
23
+ } from "@/lib/release-adoption";
24
+ import { getAdoptionReleaseRpc } from "@/lib/release-adoption-rpc";
25
+
26
+ import { formatDeployedAt } from "./ComparedBundlesChart";
27
+ import { platformLabel } from "./InsightsScope";
28
+
29
+ type ReleaseGroup = {
30
+ readonly value: string;
31
+ readonly items: readonly AdoptionRelease[];
32
+ };
33
+
34
+ /** Whether a bundle deployment matches what was typed: its IDs or target app version. */
35
+ const matches = (release: AdoptionRelease, typed: string) =>
36
+ typed === "" ||
37
+ release.releaseId.includes(typed) ||
38
+ release.bundleId.toLowerCase().includes(typed) ||
39
+ (release.targetAppVersion ?? "").toLowerCase().includes(typed);
40
+
41
+ /** A bundle deployment as a picker names it: ID, platform, deployment, target, and state. */
42
+ export function ReleaseOption({
43
+ release,
44
+ }: {
45
+ readonly release: AdoptionRelease;
46
+ }) {
47
+ return (
48
+ <span className="flex min-w-0 flex-col gap-0.5">
49
+ <span className="flex min-w-0 items-center gap-1.5">
50
+ <PlatformIcon platform={release.platform} className="size-3 shrink-0" />
51
+ <span className="sr-only">{platformLabel(release.platform)}</span>
52
+ <BundleIdDisplay bundleId={release.releaseId} className="min-w-0" />
53
+ </span>
54
+ <span className="text-xs text-muted-foreground">
55
+ Deployed {formatDeployedAt(release.deployedAtMs)}
56
+ {release.targetAppVersion ? ` · ${release.targetAppVersion}` : ""}
57
+ {release.enabled ? null : (
58
+ <>
59
+ {" · "}
60
+ <Badge variant="outline">Disabled</Badge>
61
+ </>
62
+ )}
63
+ {release.enabled && !release.live ? (
64
+ <>
65
+ {" · "}
66
+ <span className="inline-flex items-center gap-1 align-middle">
67
+ <CircleOff aria-hidden="true" className="size-3" />
68
+ Unreachable
69
+ </span>
70
+ </>
71
+ ) : null}
72
+ </span>
73
+ </span>
74
+ );
75
+ }
76
+
77
+ /**
78
+ * Picks one of a channel's bundle deployments: live ones first, searchable
79
+ * by ID or target app version. A full release ID the list leaves out is
80
+ * looked up in the scope, with the read Release health uses for it.
81
+ */
82
+ export function ReleaseCombobox({
83
+ scope,
84
+ candidates,
85
+ excluded = [],
86
+ onPick,
87
+ disabled = false,
88
+ label,
89
+ placeholder,
90
+ trigger,
91
+ }: {
92
+ readonly scope: AdoptionReleasesInput;
93
+ readonly candidates: readonly AdoptionRelease[];
94
+ readonly excluded?: readonly string[];
95
+ readonly onPick: (release: AdoptionRelease) => void;
96
+ readonly disabled?: boolean;
97
+ /** The input's accessible name. */
98
+ readonly label: string;
99
+ readonly placeholder: string;
100
+ /** A button that opens the list; the search input then sits in the list. */
101
+ readonly trigger?: ReactNode;
102
+ }) {
103
+ const [query, setQuery] = useState("");
104
+ const typed = query.trim().toLowerCase();
105
+ const offered = candidates.filter(
106
+ (release) =>
107
+ !excluded.includes(release.releaseId) && matches(release, typed),
108
+ );
109
+ const lookupId =
110
+ offered.length === 0 &&
111
+ isUUIDv7(typed) &&
112
+ !candidates.some((release) => release.releaseId === typed)
113
+ ? typed
114
+ : undefined;
115
+ const lookup = useQuery({
116
+ queryKey: ["insights", "adoption-release", scope, lookupId, false],
117
+ queryFn: () =>
118
+ getAdoptionReleaseRpc({ data: { ...scope, releaseId: lookupId! } }),
119
+ enabled: lookupId !== undefined,
120
+ staleTime: Number.POSITIVE_INFINITY,
121
+ });
122
+ const found =
123
+ lookupId !== undefined &&
124
+ lookup.data?.release &&
125
+ !excluded.includes(lookup.data.release.releaseId)
126
+ ? [lookup.data.release]
127
+ : [];
128
+ const groups: ReleaseGroup[] = [
129
+ { value: "Live", items: offered.filter((release) => release.live) },
130
+ { value: "Not live", items: offered.filter((release) => !release.live) },
131
+ { value: "Found", items: found },
132
+ ].filter((group) => group.items.length > 0);
133
+ const input = (
134
+ <ComboboxInput
135
+ aria-label={label}
136
+ className={
137
+ trigger
138
+ ? undefined
139
+ : "h-11 w-full sm:h-9 sm:w-96 [&_input]:h-full [&_input]:text-base sm:[&_input]:text-xs"
140
+ }
141
+ disabled={disabled}
142
+ placeholder={placeholder}
143
+ showTrigger={!trigger}
144
+ maxLength={1024}
145
+ />
146
+ );
147
+
148
+ return (
149
+ <Combobox
150
+ items={groups}
151
+ filter={null}
152
+ value={null}
153
+ inputValue={query}
154
+ onInputValueChange={setQuery}
155
+ itemToStringLabel={(release: AdoptionRelease) => release.releaseId}
156
+ onValueChange={(release: AdoptionRelease | null) => {
157
+ if (release === null) return;
158
+ setQuery("");
159
+ onPick(release);
160
+ }}
161
+ disabled={disabled}
162
+ >
163
+ {trigger ?? input}
164
+ <ComboboxContent className={trigger ? "w-96" : undefined}>
165
+ {trigger ? input : null}
166
+ <ComboboxEmpty>
167
+ {lookupId === undefined
168
+ ? "No matching bundles."
169
+ : lookup.isPending
170
+ ? "Looking up the release…"
171
+ : lookup.isError
172
+ ? "Couldn't look up the release."
173
+ : `No bundle with this ID in ${scope.channel} · ${platformLabel(scope.platform)}.`}
174
+ </ComboboxEmpty>
175
+ <ComboboxList>
176
+ {(group: ReleaseGroup) => (
177
+ <ComboboxGroup key={group.value} items={group.items}>
178
+ <ComboboxLabel>{group.value}</ComboboxLabel>
179
+ <ComboboxCollection>
180
+ {(release: AdoptionRelease) => (
181
+ <ComboboxItem
182
+ className="min-h-11 pr-8 sm:min-h-8"
183
+ key={release.releaseId}
184
+ value={release}
185
+ >
186
+ <ReleaseOption release={release} />
187
+ </ComboboxItem>
188
+ )}
189
+ </ComboboxCollection>
190
+ </ComboboxGroup>
191
+ )}
192
+ </ComboboxList>
193
+ </ComboboxContent>
194
+ </Combobox>
195
+ );
196
+ }
@@ -33,11 +33,14 @@ export function RollbackButton({
33
33
  rate,
34
34
  crashes,
35
35
  attempts,
36
+ onRolledBack,
36
37
  }: {
37
38
  readonly release: AdoptionRelease;
38
39
  readonly rate: number;
39
40
  readonly crashes: number;
40
41
  readonly attempts: number;
42
+ /** Runs once the release is disabled, before the bundle list reloads. */
43
+ readonly onRolledBack?: () => void;
41
44
  }) {
42
45
  const [open, setOpen] = useState(false);
43
46
  const [error, setError] = useState("");
@@ -56,6 +59,7 @@ export function RollbackButton({
56
59
  try {
57
60
  await preflight.mutateAsync(input);
58
61
  await update.mutateAsync(input);
62
+ onRolledBack?.();
59
63
  await queryClient.invalidateQueries({
60
64
  queryKey: ["insights", "adoption-releases"],
61
65
  });
@@ -324,10 +324,13 @@ function FailuresReport({
324
324
  */
325
325
  export function UpdateFailures({
326
326
  errors,
327
+ scope,
327
328
  query,
328
329
  onRefresh,
329
330
  }: {
330
331
  readonly errors?: ReactNode;
332
+ /** What the report reads, under its title. */
333
+ readonly scope?: ReactNode;
331
334
  readonly query: {
332
335
  readonly data: UpdateFailuresReport | undefined;
333
336
  readonly error: Error | null;
@@ -341,7 +344,10 @@ export function UpdateFailures({
341
344
  <Card className="min-w-0 overflow-hidden shadow-sm">
342
345
  <CardHeader className="flex flex-col gap-1.5 p-4 sm:p-6">
343
346
  <div className="flex flex-wrap items-center justify-between gap-4">
344
- <CardTitle>Update failures</CardTitle>
347
+ <div className="flex min-w-0 flex-col gap-1">
348
+ <CardTitle>Update failures</CardTitle>
349
+ {scope}
350
+ </div>
345
351
  <Button
346
352
  aria-label="Refresh update failures"
347
353
  variant="ghost"
@@ -14,10 +14,12 @@ export const getUpdateFailuresRpc = createServerFn({ method: "GET" })
14
14
  import("./server/updateFailures"),
15
15
  import("./server/runtime.server"),
16
16
  ]);
17
- const { runtime } = await prepareConfig();
17
+ const { runtime, core } = await prepareConfig();
18
18
  return getUpdateFailuresReport(
19
19
  await requireFeature(runtime, "insights"),
20
20
  data,
21
+ Date.now(),
22
+ core,
21
23
  );
22
24
  });
23
25
 
@@ -10,7 +10,8 @@ import type { InsightsEventRow } from "./insights-view";
10
10
  export type { InsightsFailureBreakdown };
11
11
 
12
12
  export type UpdateFailuresInput = {
13
- readonly platform: "ios" | "android";
13
+ /** One platform's failures, or both platforms' together. */
14
+ readonly platform: "all" | "ios" | "android";
14
15
  readonly channel: string;
15
16
  /** A release's failures; the channel's without it. */
16
17
  readonly releaseId?: string;
@@ -34,7 +35,9 @@ export function readUpdateFailuresInput(
34
35
  ): UpdateFailuresInput {
35
36
  if (
36
37
  !input ||
37
- (input.platform !== "ios" && input.platform !== "android") ||
38
+ (input.platform !== "all" &&
39
+ input.platform !== "ios" &&
40
+ input.platform !== "android") ||
38
41
  typeof input.channel !== "string" ||
39
42
  !input.channel.trim() ||
40
43
  input.channel.length > 1_024 ||
@@ -8,6 +8,11 @@ export type RecoveryInput = {
8
8
  readonly releaseId?: string;
9
9
  };
10
10
 
11
+ /** The Insights page's Release health and Update failures scope: one platform or both. */
12
+ export type ReleaseHealthInput = Omit<RecoveryInput, "platform"> & {
13
+ readonly platform: "all" | "ios" | "android";
14
+ };
15
+
11
16
  export const recoveryWindows = {
12
17
  "24h": { durationMs: 86_400_000, intervalMs: 3_600_000 },
13
18
  "7d": { durationMs: 7 * 86_400_000, intervalMs: 6 * 3_600_000 },
@@ -17,42 +17,38 @@ const readHealthChart = (value: unknown): HealthChart | undefined =>
17
17
  const readUsageWindow = (value: unknown): UsageWindow | undefined =>
18
18
  value === "12m" ? value : readWindow(value);
19
19
 
20
+ /** The channel Insights reads when none is chosen: the SDK's and the CLI's default. */
21
+ export const DEFAULT_INSIGHTS_CHANNEL = "production";
22
+
20
23
  export type InsightsSearch = {
24
+ /** Every report's platform; both when omitted. */
21
25
  platform?: AppUsageScope["platform"];
26
+ /** Every report's channel; production when omitted. */
22
27
  channel?: string;
23
28
  appVersion?: string;
24
29
  window?: UsageWindow;
25
30
  bundleWindow?: InsightsWindow;
26
- healthPlatform?: "ios" | "android";
27
- healthChannel?: string;
28
31
  releaseId?: string;
29
32
  healthChart?: HealthChart;
30
33
  /**
31
34
  * Release health's compared bundles, comma-separated release IDs; omitted,
32
- * the focused release and the one before it, or the newest two.
35
+ * the focused release and the one before it, or the live ones.
33
36
  */
34
37
  bundles?: string;
35
38
  };
36
39
 
40
+ const readPlatform = (value: unknown) =>
41
+ value === "all" || value === "ios" || value === "android" ? value : undefined;
42
+
37
43
  export function validateInsightsSearch(
38
44
  search: Record<string, unknown>,
39
45
  ): InsightsSearch {
40
46
  return {
41
- platform:
42
- search.platform === "all" ||
43
- search.platform === "ios" ||
44
- search.platform === "android"
45
- ? search.platform
46
- : undefined,
47
+ platform: readPlatform(search.platform),
47
48
  channel: readText(search.channel),
48
49
  appVersion: readText(search.appVersion),
49
50
  window: readUsageWindow(search.window),
50
51
  bundleWindow: readWindow(search.bundleWindow),
51
- healthPlatform:
52
- search.healthPlatform === "ios" || search.healthPlatform === "android"
53
- ? search.healthPlatform
54
- : undefined,
55
- healthChannel: readText(search.healthChannel),
56
52
  releaseId: readText(search.releaseId),
57
53
  healthChart: readHealthChart(search.healthChart),
58
54
  bundles: readText(search.bundles),