@hot-updater/console 1.0.0-rc.2 → 1.0.0-rc.3

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 (110) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/InsightsErrorAlert-1cg6LDJG.js +1 -0
  3. package/.output/public/assets/InsightsPageHeader-oNIg_lcd.js +1 -0
  4. package/.output/public/assets/InsightsRolloutCard-Zsv9gBjU.js +42 -0
  5. package/.output/public/assets/{api-keys-BzKocqRy.js → api-keys-ChJjsbP2.js} +1 -1
  6. package/.output/public/assets/{api-keys-api-BHcODB7q.js → api-keys-api-LchP65vQ.js} +1 -1
  7. package/.output/public/assets/{card-Dhr_eFYj.js → card-iD7iiy4T.js} +1 -1
  8. package/.output/public/assets/{chart-no-axes-combined-Dy4WJv2U.js → chart-no-axes-combined-C0G0fQ3J.js} +1 -1
  9. package/.output/public/assets/createServerFn-DHc2erub.js +3 -0
  10. package/.output/public/assets/dialog-DX6o8qCL.js +1 -0
  11. package/.output/public/assets/{dist-C_NydL0e.js → dist-BPrb_vuw.js} +1 -1
  12. package/.output/public/assets/index-C6Nb14xG.js +10 -0
  13. package/.output/public/assets/{input-group-cSOG4wh_.js → input-group-Pa3SAbEI.js} +1 -1
  14. package/.output/public/assets/insights-C1uKy3p2.js +1 -0
  15. package/.output/public/assets/installations-Bj5AzZ3T.js +1 -0
  16. package/.output/public/assets/{installations-CE33wgbY.js → installations-DDTw9DSK.js} +2 -2
  17. package/.output/public/assets/{lazyRouteComponent-Ccp3bwBY.js → lazyRouteComponent-BN0MS05o.js} +1 -1
  18. package/.output/public/assets/{link-CI8idAUi.js → link-BG06whwB.js} +1 -1
  19. package/.output/public/assets/{loader-circle-BoxLrsOu.js → loader-circle-Bbp8LGVC.js} +1 -1
  20. package/.output/public/assets/{refresh-cw-Bqc72UZG.js → refresh-cw-DZgiAOMk.js} +1 -1
  21. package/.output/public/assets/routes-9E2okfpG.js +1 -0
  22. package/.output/public/assets/{routes-DGVXdzpw.js → routes-B3JviaDV.js} +2 -2
  23. package/.output/public/assets/{shield-check-uK3MHWJZ.js → shield-check-DoIiaaXU.js} +1 -1
  24. package/.output/public/assets/{signing-CMf4aqDO.js → signing-BKy9fIdB.js} +1 -1
  25. package/.output/public/assets/styles-C5-su40i.css +2 -0
  26. package/.output/public/assets/{table-2vcV89QB.js → table-bpgpQkPz.js} +1 -1
  27. package/.output/public/assets/useOpenInteractionType-4dPacbwb.js +1 -0
  28. package/.output/public/assets/x-Cr_sPM8E.js +1 -0
  29. package/.output/server/{__23tanstack-start-server-fn-resolver-B7ioJhtE.mjs → __23tanstack-start-server-fn-resolver-EMel9KYs.mjs} +36 -32
  30. package/.output/server/_libs/@base-ui/react+[...].mjs +1051 -1051
  31. package/.output/server/_libs/@reduxjs/toolkit+[...].mjs +1554 -0
  32. package/.output/server/_libs/@tanstack/react-router+[...].mjs +1 -1
  33. package/.output/server/_libs/@tanstack/react-router-ssr-query+[...].mjs +1 -1
  34. package/.output/server/_libs/d3-array.mjs +231 -0
  35. package/.output/server/_libs/d3-color.mjs +330 -0
  36. package/.output/server/_libs/d3-format.mjs +252 -0
  37. package/.output/server/_libs/d3-interpolate.mjs +207 -0
  38. package/.output/server/_libs/d3-path.mjs +77 -0
  39. package/.output/server/_libs/d3-scale+[...].mjs +1907 -0
  40. package/.output/server/_libs/d3-shape.mjs +818 -0
  41. package/.output/server/_libs/decimal.js-light.mjs +884 -0
  42. package/.output/server/_libs/es-toolkit.mjs +1159 -0
  43. package/.output/server/_libs/eventemitter3.mjs +236 -0
  44. package/.output/server/_libs/immer.mjs +532 -0
  45. package/.output/server/_libs/lucide-react.mjs +1 -1
  46. package/.output/server/_libs/next-themes.mjs +1 -1
  47. package/.output/server/_libs/react-redux+[...].mjs +205 -0
  48. package/.output/server/_libs/recharts+[...].mjs +14636 -0
  49. package/.output/server/_libs/sonner.mjs +1 -1
  50. package/.output/server/_libs/tanstack__react-query.mjs +1 -1
  51. package/.output/server/_runtime.mjs +10 -1
  52. package/.output/server/_ssr/InsightsErrorAlert-DzDVdfgW.mjs +169 -0
  53. package/.output/server/_ssr/{InsightsPageHeader-Df4Brj8u.mjs → InsightsPageHeader-CZ2AaQGk.mjs} +8 -120
  54. package/.output/server/_ssr/InsightsRolloutCard-DHB2iM4t.mjs +693 -0
  55. package/.output/server/_ssr/{api-keys-CZeTPQys.mjs → api-keys-BBl84_lN.mjs} +7 -7
  56. package/.output/server/_ssr/{api-keys-api-BUPQVSpk.mjs → api-keys-api-KSH04dLB.mjs} +2 -2
  57. package/.output/server/_ssr/{api-keys-rpc-Cs7tS5_j.mjs → api-keys-rpc-DBmgnW5o.mjs} +3 -3
  58. package/.output/server/_ssr/{api-rpc-tAupfGQm.mjs → api-rpc-DX5B6b-L.mjs} +25 -25
  59. package/.output/server/_ssr/{auth-rpc-DUaeOWKK.mjs → auth-rpc-gaibR719.mjs} +4 -4
  60. package/.output/server/_ssr/{auth.server-DW71WzsD.mjs → auth.server-DF_shCeq.mjs} +1 -1
  61. package/.output/server/_ssr/{bundle-signing-rpc-BhnBw0-d.mjs → bundle-signing-rpc-C01EKpb-.mjs} +2 -2
  62. package/.output/server/_ssr/{bundle-signing.server-C4NW5mWx.mjs → bundle-signing.server-Co_zHdJx.mjs} +1 -1
  63. package/.output/server/_ssr/{card-BRiPmi0C.mjs → card-gGh8F6kQ.mjs} +1 -1
  64. package/.output/server/_ssr/{config.server-afDuAwaG.mjs → config.server-NIXXHk-e.mjs} +5 -5
  65. package/.output/server/_ssr/{createSsrRpc-DjWNJ0uc.mjs → createSsrRpc-4rr4IJh3.mjs} +3 -3
  66. package/.output/server/_ssr/{deleteBundle-BTGwBgzx.mjs → deleteBundle-C71v1GBr.mjs} +1 -1
  67. package/.output/server/_ssr/{dialog-DS-nw-pA.mjs → dialog-DNtKYR5K.mjs} +1 -1
  68. package/.output/server/_ssr/{downloadBundle-CNnYxYO2.mjs → downloadBundle-A9zYC2go.mjs} +1 -1
  69. package/.output/server/_ssr/{getBundleChildren-C0vh4ZvE.mjs → getBundleChildren-CMGSGBWv.mjs} +1 -1
  70. package/.output/server/_ssr/{input-group-BpsnmvZw.mjs → input-group-Oj4kUnE4.mjs} +1 -1
  71. package/.output/server/_ssr/{insights-BdS364Xu.mjs → insights-oK2n5WBp.mjs} +13 -67
  72. package/.output/server/_ssr/insights-recovery-DSFM-YTL.mjs +21 -0
  73. package/.output/server/_ssr/insights-recovery-rpc-CgeCAs2K.mjs +16 -0
  74. package/.output/server/_ssr/{insights-rpc-38DujK0G.mjs → insights-rpc-DBf0ouen.mjs} +2 -2
  75. package/.output/server/_ssr/insightsRecovery-DFkXGFjd.mjs +108 -0
  76. package/.output/server/_ssr/{installations-CtA59MQG.mjs → installations-DBmYuVBi.mjs} +2 -2
  77. package/.output/server/_ssr/{installations-DiVKEF5T.mjs → installations-DQyJN-pZ.mjs} +9 -9
  78. package/.output/server/_ssr/{router-B9wG_9f8.mjs → router-C8J4nYEG.mjs} +13 -13
  79. package/.output/server/_ssr/{routes-CKrrS2q2.mjs → routes-C2RIYhyl.mjs} +2 -2
  80. package/.output/server/_ssr/{routes-D7Yh_jfj.mjs → routes-NlTRKPML.mjs} +21 -86
  81. package/.output/server/_ssr/{runtime.server-DacU1TBV.mjs → runtime.server-DLZpzphL.mjs} +1 -1
  82. package/.output/server/_ssr/{server-D_C2rMzf.mjs → server-hgrXpJQY.mjs} +3 -3
  83. package/.output/server/_ssr/{signing-Dz1WkZOf.mjs → signing-bfOvUMSJ.mjs} +3 -3
  84. package/.output/server/_ssr/ssr.mjs +1 -1
  85. package/.output/server/_ssr/{table-CnZmon_Z.mjs → table-QQ9cnWf2.mjs} +3 -3
  86. package/.output/server/_tanstack-start-manifest_v-BAb_a_eC.mjs +97 -0
  87. package/.output/server/index.mjs +152 -152
  88. package/package.json +5 -5
  89. package/src/components/features/insights/InsightsRolloutCard.tsx +505 -0
  90. package/src/components/features/releases/ReleaseEditorSheet.tsx +13 -0
  91. package/src/components/ui/empty.tsx +101 -0
  92. package/src/lib/insights-recovery-rpc.ts +14 -0
  93. package/src/lib/insights-recovery.ts +55 -0
  94. package/src/lib/server/insightsRecovery.ts +157 -0
  95. package/src/routes/insights.tsx +2 -0
  96. package/.output/public/assets/HashValueDisplay-CB0Eskit.js +0 -1
  97. package/.output/public/assets/InsightsPageHeader-Car5LNiZ.js +0 -1
  98. package/.output/public/assets/composite-Bp98A0Ld.js +0 -1
  99. package/.output/public/assets/createServerFn-CA8fs7QD.js +0 -3
  100. package/.output/public/assets/dialog-CUTrcPlB.js +0 -1
  101. package/.output/public/assets/index-DdAz-CXv.js +0 -10
  102. package/.output/public/assets/insights-C0PBSZ-n.js +0 -1
  103. package/.output/public/assets/installations-CStZja8x.js +0 -1
  104. package/.output/public/assets/routes-DTlwOr8I.js +0 -1
  105. package/.output/public/assets/styles-BCvbtKsm.css +0 -2
  106. package/.output/public/assets/useCompositeListItem-CHLITE_4.js +0 -1
  107. package/.output/public/assets/x-DCd0j8c6.js +0 -1
  108. package/.output/server/_ssr/HashValueDisplay-DwpGLfrl.mjs +0 -55
  109. package/.output/server/_tanstack-start-manifest_v-2MjTyxil.mjs +0 -97
  110. /package/.output/public/assets/{preload-helper-CQ36nxok.js → preload-helper-B_l5jfgx.js} +0 -0
@@ -0,0 +1,505 @@
1
+ import { useQuery } from "@tanstack/react-query";
2
+ import { Link } from "@tanstack/react-router";
3
+ import { ArrowUpRight, RotateCcw, TriangleAlert } from "lucide-react";
4
+ import { useState } from "react";
5
+ import {
6
+ CartesianGrid,
7
+ Line,
8
+ LineChart,
9
+ ReferenceLine,
10
+ XAxis,
11
+ YAxis,
12
+ } from "recharts";
13
+
14
+ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
15
+ import { Button, buttonVariants } from "@/components/ui/button";
16
+ import {
17
+ Card,
18
+ CardContent,
19
+ CardDescription,
20
+ CardFooter,
21
+ CardHeader,
22
+ CardTitle,
23
+ } from "@/components/ui/card";
24
+ import {
25
+ ChartContainer,
26
+ ChartTooltip,
27
+ ChartTooltipContent,
28
+ } from "@/components/ui/chart";
29
+ import {
30
+ Empty,
31
+ EmptyDescription,
32
+ EmptyHeader,
33
+ EmptyMedia,
34
+ EmptyTitle,
35
+ } from "@/components/ui/empty";
36
+ import {
37
+ Select,
38
+ SelectContent,
39
+ SelectGroup,
40
+ SelectItem,
41
+ SelectTrigger,
42
+ SelectValue,
43
+ } from "@/components/ui/select";
44
+ import { Skeleton } from "@/components/ui/skeleton";
45
+ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
46
+ import type { RecoveryInput, RecoveryReport } from "@/lib/insights-recovery";
47
+ import { getRecoveryReportRpc } from "@/lib/insights-recovery-rpc";
48
+
49
+ import { useInsightsTimeFormat } from "./EventDetails";
50
+ import { InsightsErrorAlert } from "./InsightsErrorAlert";
51
+
52
+ const percent = (rate: number | null) =>
53
+ rate === null ? "No reports" : `${rate.toFixed(1)}%`;
54
+
55
+ export function RolloutChart({
56
+ report,
57
+ inSheet = false,
58
+ }: {
59
+ readonly report: RecoveryReport;
60
+ readonly inSheet?: boolean;
61
+ }) {
62
+ const [selectedId, setSelectedId] = useState<string | null>(null);
63
+ const [selectedTime, setSelectedTime] = useState<number | null>(null);
64
+ const [metric, setMetric] = useState<"adoption" | "recovery">(() =>
65
+ report.series.some(({ points }) => points.some(({ spike }) => spike))
66
+ ? "recovery"
67
+ : "adoption",
68
+ );
69
+ const formatter = useInsightsTimeFormat();
70
+ const series =
71
+ report.series.find(({ releaseId }) => releaseId === selectedId) ??
72
+ report.series[0];
73
+ if (!series)
74
+ return (
75
+ <Empty>
76
+ <EmptyHeader>
77
+ <EmptyMedia variant="icon">
78
+ <RotateCcw aria-hidden="true" />
79
+ </EmptyMedia>
80
+ <EmptyTitle>No rollout activity yet</EmptyTitle>
81
+ <EmptyDescription>
82
+ Try a different time window or check again after this deployment
83
+ reports adoption or recovery.
84
+ </EmptyDescription>
85
+ </EmptyHeader>
86
+ </Empty>
87
+ );
88
+
89
+ const point =
90
+ series.points.find(({ startMs }) => startMs === selectedTime) ??
91
+ [...series.points].reverse().find(({ spike }) => spike) ??
92
+ [...series.points]
93
+ .reverse()
94
+ .find(
95
+ (item) =>
96
+ (metric === "adoption" ? item.adoptionShare : item.rate) !== null,
97
+ ) ??
98
+ [...series.points].reverse().find(({ rate }) => rate !== null)!;
99
+ const markerMs =
100
+ series.firstAdoptedAtMs === null
101
+ ? null
102
+ : Math.floor(series.firstAdoptedAtMs / report.intervalMs) *
103
+ report.intervalMs;
104
+ const spikeCount = report.series.filter(({ points }) =>
105
+ points.some(({ spike }) => spike),
106
+ ).length;
107
+ // Paint the selected ID last so it stays clickable where lines cross.
108
+ const plotted =
109
+ metric === "adoption"
110
+ ? [...report.series.filter((item) => item !== series), series]
111
+ : [series];
112
+ const chartConfig = Object.fromEntries(
113
+ plotted.map((item, index) => [
114
+ `series${index}`,
115
+ {
116
+ label: `ID ${item.releaseId}`,
117
+ color:
118
+ item.releaseId === series.releaseId
119
+ ? "var(--primary)"
120
+ : "var(--muted-foreground)",
121
+ },
122
+ ]),
123
+ );
124
+ const chartData = series.points.map((item, pointIndex) => ({
125
+ startMs: item.startMs,
126
+ ...Object.fromEntries(
127
+ plotted.map((deployment, index) => [
128
+ `series${index}`,
129
+ metric === "adoption"
130
+ ? deployment.points[pointIndex].adoptionShare
131
+ : deployment.points[pointIndex].rate,
132
+ ]),
133
+ ),
134
+ }));
135
+ const selectedValue =
136
+ metric === "adoption" ? point.adoptionShare : point.rate;
137
+ const tickTime = (value: number) =>
138
+ new Intl.DateTimeFormat("en", {
139
+ timeZone: formatter.resolvedOptions().timeZone,
140
+ ...(report.intervalMs < 21_600_000
141
+ ? { hour: "2-digit", minute: "2-digit" }
142
+ : { month: "short", day: "numeric" }),
143
+ }).format(value);
144
+
145
+ return (
146
+ <div className="flex min-w-0 flex-col gap-4">
147
+ <ToggleGroup
148
+ aria-label="Rollout metric"
149
+ variant="outline"
150
+ spacing={0}
151
+ value={[metric]}
152
+ onValueChange={(values) => {
153
+ const next = values[0];
154
+ if (next === "adoption" || next === "recovery") {
155
+ setSelectedTime(point.startMs);
156
+ setMetric(next);
157
+ }
158
+ }}
159
+ >
160
+ <ToggleGroupItem value="adoption">New adoption</ToggleGroupItem>
161
+ <ToggleGroupItem value="recovery">Recovery rate</ToggleGroupItem>
162
+ </ToggleGroup>
163
+ {!inSheet ? (
164
+ <div className="flex min-w-0 flex-col gap-2">
165
+ <Select
166
+ value={series.releaseId}
167
+ onValueChange={(value) => {
168
+ setSelectedId(value);
169
+ setSelectedTime(null);
170
+ }}
171
+ >
172
+ <SelectTrigger
173
+ aria-label="Deployment ID"
174
+ className="w-full min-w-0"
175
+ >
176
+ <SelectValue />
177
+ </SelectTrigger>
178
+ <SelectContent>
179
+ <SelectGroup>
180
+ {report.series.map((item) => (
181
+ <SelectItem key={item.releaseId} value={item.releaseId}>
182
+ {item.points.some(({ spike }) => spike)
183
+ ? "Review · "
184
+ : "ID · "}
185
+ {item.releaseId}
186
+ </SelectItem>
187
+ ))}
188
+ </SelectGroup>
189
+ </SelectContent>
190
+ </Select>
191
+ {spikeCount > 1 ? (
192
+ <p className="text-xs text-muted-foreground">
193
+ {spikeCount} deployments have recovery spikes. Select an ID to
194
+ review its trend.
195
+ </p>
196
+ ) : null}
197
+ </div>
198
+ ) : null}
199
+ {metric === "adoption" && !inSheet ? (
200
+ <p className="text-xs text-muted-foreground">
201
+ The highlighted line is the selected ID. Other IDs appear in gray;
202
+ select a point to compare adoption and recovery.
203
+ </p>
204
+ ) : null}
205
+ <ChartContainer
206
+ config={chartConfig}
207
+ className="h-56 w-full"
208
+ aria-label={`${metric === "adoption" ? "New adoption share" : "Recovery rate"} for ID ${series.releaseId}`}
209
+ >
210
+ <LineChart
211
+ accessibilityLayer
212
+ data={chartData}
213
+ margin={{ top: 24, left: -20, right: 16 }}
214
+ onClick={(state) => {
215
+ const index = Number(state.activeIndex);
216
+ if (state.activeIndex != null && series.points[index])
217
+ setSelectedTime(series.points[index].startMs);
218
+ }}
219
+ >
220
+ <CartesianGrid vertical={false} />
221
+ <XAxis
222
+ tick={{ fill: "var(--muted-foreground)" }}
223
+ dataKey="startMs"
224
+ tickFormatter={tickTime}
225
+ tickLine={false}
226
+ axisLine={false}
227
+ minTickGap={48}
228
+ />
229
+ <YAxis
230
+ tick={{ fill: "var(--muted-foreground)" }}
231
+ domain={[0, 100]}
232
+ tickFormatter={(value: number) => `${value}%`}
233
+ tickLine={false}
234
+ axisLine={false}
235
+ ticks={[0, 25, 50, 75, 100]}
236
+ />
237
+ <ChartTooltip
238
+ content={
239
+ <ChartTooltipContent
240
+ labelFormatter={(_, payload) =>
241
+ formatter.format(Number(payload[0]?.payload.startMs))
242
+ }
243
+ formatter={(value, name) => (
244
+ <span className="max-w-64 break-all font-medium tabular-nums">
245
+ {name}: {Number(value).toFixed(1)}%
246
+ </span>
247
+ )}
248
+ />
249
+ }
250
+ />
251
+ {markerMs !== null ? (
252
+ <ReferenceLine
253
+ x={markerMs}
254
+ stroke="var(--muted-foreground)"
255
+ strokeDasharray="4 4"
256
+ label={{
257
+ value: "First adoption",
258
+ position: "insideTopLeft",
259
+ fill: "var(--muted-foreground)",
260
+ fontSize: 11,
261
+ }}
262
+ />
263
+ ) : null}
264
+ <ReferenceLine x={point.startMs} stroke="var(--border)" />
265
+ {plotted.map((deployment, seriesIndex) => (
266
+ <Line
267
+ key={deployment.releaseId}
268
+ name={`ID ${deployment.releaseId}`}
269
+ dataKey={`series${seriesIndex}`}
270
+ type="linear"
271
+ stroke={`var(--color-series${seriesIndex})`}
272
+ strokeWidth={2}
273
+ connectNulls={false}
274
+ isAnimationActive={false}
275
+ dot={({ cx, cy, index }) => {
276
+ const p = deployment.points[index];
277
+ const value =
278
+ metric === "adoption" ? p?.adoptionShare : p?.rate;
279
+ if (!p || value === null) return <g key={index} />;
280
+ // SVG chart points cannot contain native HTML buttons.
281
+ /* oxlint-disable jsx-a11y/prefer-tag-over-role */
282
+ return (
283
+ <circle
284
+ key={index}
285
+ role="button"
286
+ tabIndex={0}
287
+ aria-label={`Inspect ID ${deployment.releaseId}, ${formatter.format(p.startMs)}: ${percent(value)} ${metric === "adoption" ? "new adoption" : "recovered"}`}
288
+ className="cursor-pointer focus-visible:stroke-foreground focus-visible:stroke-2"
289
+ onClick={(event) => {
290
+ event.stopPropagation();
291
+ setSelectedId(deployment.releaseId);
292
+ setSelectedTime(p.startMs);
293
+ }}
294
+ onKeyDown={(event) => {
295
+ if (event.key === "Enter" || event.key === " ") {
296
+ event.preventDefault();
297
+ event.stopPropagation();
298
+ setSelectedId(deployment.releaseId);
299
+ setSelectedTime(p.startMs);
300
+ }
301
+ }}
302
+ cx={cx}
303
+ cy={cy}
304
+ r={metric === "recovery" && p.spike ? 5 : 3}
305
+ fill={
306
+ metric === "recovery" && p.spike
307
+ ? "var(--destructive)"
308
+ : `var(--color-series${seriesIndex})`
309
+ }
310
+ />
311
+ );
312
+ /* oxlint-enable jsx-a11y/prefer-tag-over-role */
313
+ }}
314
+ />
315
+ ))}
316
+ </LineChart>
317
+ </ChartContainer>
318
+ <div className="flex flex-wrap items-end justify-between gap-4">
319
+ <div>
320
+ <p className="text-xs text-muted-foreground">
321
+ {metric === "adoption"
322
+ ? "New adoption share in selected interval"
323
+ : "Recovered in selected interval"}
324
+ </p>
325
+ <p className="mt-1 text-4xl font-semibold tracking-tight tabular-nums">
326
+ {selectedValue === null ? "—" : selectedValue.toFixed(1)}
327
+ <span className="ml-1 text-lg font-medium text-muted-foreground">
328
+ %
329
+ </span>
330
+ </p>
331
+ <p className="mt-1 text-xs text-muted-foreground">
332
+ {metric === "adoption"
333
+ ? `${point.adopted.toLocaleString()} adoption reports · ${percent(point.rate)} recovered`
334
+ : `${point.recovered.toLocaleString()} recovered / ${(point.adopted + point.recovered).toLocaleString()} adoption + recovery reports`}
335
+ </p>
336
+ </div>
337
+ <div className="flex gap-2">
338
+ <Button
339
+ variant="outline"
340
+ aria-label="Previous interval"
341
+ disabled={point === series.points[0]}
342
+ onClick={() => setSelectedTime(point.startMs - report.intervalMs)}
343
+ >
344
+ Previous
345
+ </Button>
346
+ <Button
347
+ variant="outline"
348
+ aria-label="Next interval"
349
+ disabled={point === series.points.at(-1)}
350
+ onClick={() => setSelectedTime(point.startMs + report.intervalMs)}
351
+ >
352
+ Next
353
+ </Button>
354
+ </div>
355
+ </div>
356
+ <p className="text-xs text-muted-foreground">
357
+ {formatter.format(Math.max(point.startMs, report.sinceMs))} –{" "}
358
+ {formatter.format(
359
+ Math.min(
360
+ point.startMs + report.intervalMs,
361
+ report.beforeReceivedAtMs,
362
+ ),
363
+ )}
364
+ </p>
365
+ <div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border p-3">
366
+ <div className="min-w-0">
367
+ <p className="text-xs text-muted-foreground">ID</p>
368
+ <p className="mt-1 break-all font-mono text-xs" translate="no">
369
+ {series.releaseId}
370
+ </p>
371
+ </div>
372
+ {!inSheet ? (
373
+ <Link
374
+ to="/"
375
+ search={{ releaseId: series.releaseId }}
376
+ className={buttonVariants({ variant: "outline" })}
377
+ >
378
+ Review deployment
379
+ <ArrowUpRight aria-hidden="true" data-icon="inline-end" />
380
+ </Link>
381
+ ) : null}
382
+ </div>
383
+ {point.spike ? (
384
+ <Alert variant="destructive">
385
+ <TriangleAlert aria-hidden="true" />
386
+ <AlertTitle>Consider rolling back this deployment</AlertTitle>
387
+ <AlertDescription>
388
+ Recovery rose to {percent(point.rate)} after adoption began. Review
389
+ rollout and Enabled settings before deciding to roll back.
390
+ </AlertDescription>
391
+ </Alert>
392
+ ) : null}
393
+ {markerMs !== null ? (
394
+ <p className="text-xs text-muted-foreground">
395
+ First adoption observed in this window:{" "}
396
+ {formatter.format(series.firstAdoptedAtMs!)}.
397
+ </p>
398
+ ) : null}
399
+ </div>
400
+ );
401
+ }
402
+
403
+ export function InsightsRolloutCard({
404
+ input,
405
+ inSheet = false,
406
+ }: {
407
+ readonly input: RecoveryInput;
408
+ readonly inSheet?: boolean;
409
+ }) {
410
+ const [sheetWindow, setSheetWindow] = useState(input.window);
411
+ const queryInput = inSheet ? { ...input, window: sheetWindow } : input;
412
+ const query = useQuery({
413
+ queryKey: ["insights", "recovery", queryInput],
414
+ queryFn: () => getRecoveryReportRpc({ data: queryInput }),
415
+ staleTime: 30_000,
416
+ refetchOnWindowFocus: true,
417
+ });
418
+ return (
419
+ <Card className="min-w-0 overflow-hidden shadow-sm">
420
+ <CardHeader>
421
+ <CardTitle>Rollout activity</CardTitle>
422
+ {inSheet ? (
423
+ <Select
424
+ value={sheetWindow}
425
+ onValueChange={(value) => {
426
+ if (value === "24h" || value === "7d" || value === "30d")
427
+ setSheetWindow(value);
428
+ }}
429
+ >
430
+ <SelectTrigger aria-label="Rollout time window" className="w-full">
431
+ <SelectValue />
432
+ </SelectTrigger>
433
+ <SelectContent>
434
+ <SelectGroup>
435
+ <SelectItem value="24h">Last 24 hours</SelectItem>
436
+ <SelectItem value="7d">Last 7 days</SelectItem>
437
+ <SelectItem value="30d">Last 30 days</SelectItem>
438
+ </SelectGroup>
439
+ </SelectContent>
440
+ </Select>
441
+ ) : null}
442
+ <CardDescription>
443
+ See new deployments take over and spot recovery spikes. Select a point
444
+ to inspect its ID and interval.
445
+ </CardDescription>
446
+ </CardHeader>
447
+ <CardContent className="flex flex-col gap-4">
448
+ {query.isPending ? (
449
+ <Skeleton
450
+ aria-label="Loading rollout activity"
451
+ className="h-80 w-full"
452
+ />
453
+ ) : query.error ? (
454
+ <InsightsErrorAlert
455
+ error={query.error}
456
+ fallbackTitle="Rollout activity unavailable"
457
+ />
458
+ ) : (
459
+ <>
460
+ {query.data.truncated ? (
461
+ <Alert>
462
+ <TriangleAlert aria-hidden="true" />
463
+ <AlertTitle>Partial history</AlertTitle>
464
+ <AlertDescription>
465
+ Only complete intervals from the latest 50,000 reports are
466
+ shown. Choose a shorter window to see more detail.
467
+ </AlertDescription>
468
+ </Alert>
469
+ ) : null}
470
+ <RolloutChart
471
+ key={`${queryInput.platform}:${queryInput.channel}:${queryInput.window}:${queryInput.releaseId ?? ""}`}
472
+ report={query.data}
473
+ inSheet={inSheet}
474
+ />
475
+ </>
476
+ )}
477
+ </CardContent>
478
+ <CardFooter className="flex flex-col items-start gap-3">
479
+ <p className="text-xs text-muted-foreground">
480
+ New adoption shows each ID’s share of adoption reports in this
481
+ platform and channel. Recovery is recovered ÷ (adopted + recovered).
482
+ Only reports with an ID are included. Reports are not unique devices;
483
+ gaps mean no reports.
484
+ </p>
485
+ <details className="text-xs text-muted-foreground">
486
+ <summary className="cursor-pointer">
487
+ When a spike needs review
488
+ </summary>
489
+ <p className="mt-2">
490
+ At least 10 reports, 3 recoveries, a recovery rate of 10% or more,
491
+ and a rise of 10 percentage points from the previous observed
492
+ interval (0% at first adoption).
493
+ </p>
494
+ </details>
495
+ <Button
496
+ variant="outline"
497
+ disabled={query.isFetching}
498
+ onClick={() => void query.refetch()}
499
+ >
500
+ Refresh rollout activity
501
+ </Button>
502
+ </CardFooter>
503
+ </Card>
504
+ );
505
+ }
@@ -17,6 +17,7 @@ import { normalizeRange } from "verkit";
17
17
  import { BundleIdDisplay } from "@/components/BundleIdDisplay";
18
18
  import { BundleMetadata } from "@/components/features/bundles/BundleMetadata";
19
19
  import { RolloutCohortsDialog } from "@/components/features/bundles/RolloutCohortsDialog";
20
+ import { InsightsRolloutCard } from "@/components/features/insights/InsightsRolloutCard";
20
21
  import { PlatformIcon } from "@/components/PlatformIcon";
21
22
  import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
22
23
  import {
@@ -585,6 +586,18 @@ export function ReleaseEditorSheet({
585
586
  </Button>
586
587
  </section>
587
588
 
589
+ {channelName ? (
590
+ <InsightsRolloutCard
591
+ inSheet
592
+ input={{
593
+ platform: release.platform,
594
+ channel: channelName,
595
+ releaseId: release.id,
596
+ window: "7d",
597
+ }}
598
+ />
599
+ ) : null}
600
+
588
601
  <Separator className="my-2" />
589
602
 
590
603
  <section className="flex flex-col gap-4">
@@ -0,0 +1,101 @@
1
+ import { cva, type VariantProps } from "class-variance-authority";
2
+
3
+ import { cn } from "@/lib/utils";
4
+
5
+ function Empty({ className, ...props }: React.ComponentProps<"div">) {
6
+ return (
7
+ <div
8
+ data-slot="empty"
9
+ className={cn(
10
+ "flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-xl border-dashed p-6 text-center text-balance",
11
+ className,
12
+ )}
13
+ {...props}
14
+ />
15
+ );
16
+ }
17
+
18
+ function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
19
+ return (
20
+ <div
21
+ data-slot="empty-header"
22
+ className={cn("flex max-w-sm flex-col items-center gap-1", className)}
23
+ {...props}
24
+ />
25
+ );
26
+ }
27
+
28
+ const emptyMediaVariants = cva(
29
+ "mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
30
+ {
31
+ variants: {
32
+ variant: {
33
+ default: "bg-transparent",
34
+ icon: "flex size-8 shrink-0 items-center justify-center rounded-md bg-muted text-foreground [&_svg:not([class*='size-'])]:size-4",
35
+ },
36
+ },
37
+ defaultVariants: {
38
+ variant: "default",
39
+ },
40
+ },
41
+ );
42
+
43
+ function EmptyMedia({
44
+ className,
45
+ variant = "default",
46
+ ...props
47
+ }: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
48
+ return (
49
+ <div
50
+ data-slot="empty-icon"
51
+ data-variant={variant}
52
+ className={cn(emptyMediaVariants({ variant, className }))}
53
+ {...props}
54
+ />
55
+ );
56
+ }
57
+
58
+ function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
59
+ return (
60
+ <div
61
+ data-slot="empty-title"
62
+ className={cn("text-sm font-medium tracking-tight", className)}
63
+ {...props}
64
+ />
65
+ );
66
+ }
67
+
68
+ function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
69
+ return (
70
+ <div
71
+ data-slot="empty-description"
72
+ className={cn(
73
+ "text-xs/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
74
+ className,
75
+ )}
76
+ {...props}
77
+ />
78
+ );
79
+ }
80
+
81
+ function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
82
+ return (
83
+ <div
84
+ data-slot="empty-content"
85
+ className={cn(
86
+ "flex w-full max-w-sm min-w-0 flex-col items-center gap-2 text-xs/relaxed text-balance",
87
+ className,
88
+ )}
89
+ {...props}
90
+ />
91
+ );
92
+ }
93
+
94
+ export {
95
+ Empty,
96
+ EmptyHeader,
97
+ EmptyTitle,
98
+ EmptyDescription,
99
+ EmptyContent,
100
+ EmptyMedia,
101
+ };
@@ -0,0 +1,14 @@
1
+ import { createServerFn } from "@tanstack/react-start";
2
+
3
+ import { readRecoveryInput } from "./insights-recovery";
4
+
5
+ export const getRecoveryReportRpc = createServerFn({ method: "GET" })
6
+ .validator(readRecoveryInput)
7
+ .handler(async ({ data }) => {
8
+ const [{ prepareConfig }, { getRecoveryReport }] = await Promise.all([
9
+ import("./server/config.server"),
10
+ import("./server/insightsRecovery"),
11
+ ]);
12
+ const { config } = await prepareConfig();
13
+ return getRecoveryReport(config.database.models.insights, data);
14
+ });
@@ -0,0 +1,55 @@
1
+ import type { InsightsWindow } from "./insights-rpc";
2
+
3
+ export type RecoveryInput = {
4
+ readonly platform: "ios" | "android";
5
+ readonly channel: string;
6
+ readonly window: InsightsWindow;
7
+ readonly releaseId?: string;
8
+ };
9
+
10
+ export type RecoveryPoint = {
11
+ readonly startMs: number;
12
+ readonly adopted: number;
13
+ readonly adoptionShare: number | null;
14
+ readonly recovered: number;
15
+ readonly rate: number | null;
16
+ readonly spike: boolean;
17
+ };
18
+
19
+ export type RecoverySeries = {
20
+ readonly releaseId: string;
21
+ readonly firstAdoptedAtMs: number | null;
22
+ readonly points: readonly RecoveryPoint[];
23
+ };
24
+
25
+ export type RecoveryReport = {
26
+ readonly sinceMs: number;
27
+ readonly beforeReceivedAtMs: number;
28
+ readonly intervalMs: number;
29
+ readonly truncated: boolean;
30
+ readonly series: readonly RecoverySeries[];
31
+ };
32
+
33
+ export const recoveryWindows = {
34
+ "24h": { durationMs: 86_400_000, intervalMs: 3_600_000 },
35
+ "7d": { durationMs: 7 * 86_400_000, intervalMs: 6 * 3_600_000 },
36
+ "30d": { durationMs: 30 * 86_400_000, intervalMs: 86_400_000 },
37
+ } as const;
38
+
39
+ export function readRecoveryInput(input: RecoveryInput): RecoveryInput {
40
+ if (
41
+ !input ||
42
+ (input.platform !== "ios" && input.platform !== "android") ||
43
+ typeof input.channel !== "string" ||
44
+ !input.channel.trim() ||
45
+ input.channel.length > 1_024 ||
46
+ !Object.hasOwn(recoveryWindows, input.window) ||
47
+ (input.releaseId !== undefined &&
48
+ (typeof input.releaseId !== "string" ||
49
+ !input.releaseId.trim() ||
50
+ input.releaseId.length > 1_024))
51
+ ) {
52
+ throw new Error("Choose a platform, channel, and time window.");
53
+ }
54
+ return input;
55
+ }