@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.
- package/.output/nitro.json +1 -1
- package/.output/public/assets/InsightsErrorAlert-1cg6LDJG.js +1 -0
- package/.output/public/assets/InsightsPageHeader-oNIg_lcd.js +1 -0
- package/.output/public/assets/InsightsRolloutCard-Zsv9gBjU.js +42 -0
- package/.output/public/assets/{api-keys-BzKocqRy.js → api-keys-ChJjsbP2.js} +1 -1
- package/.output/public/assets/{api-keys-api-BHcODB7q.js → api-keys-api-LchP65vQ.js} +1 -1
- package/.output/public/assets/{card-Dhr_eFYj.js → card-iD7iiy4T.js} +1 -1
- package/.output/public/assets/{chart-no-axes-combined-Dy4WJv2U.js → chart-no-axes-combined-C0G0fQ3J.js} +1 -1
- package/.output/public/assets/createServerFn-DHc2erub.js +3 -0
- package/.output/public/assets/dialog-DX6o8qCL.js +1 -0
- package/.output/public/assets/{dist-C_NydL0e.js → dist-BPrb_vuw.js} +1 -1
- package/.output/public/assets/index-C6Nb14xG.js +10 -0
- package/.output/public/assets/{input-group-cSOG4wh_.js → input-group-Pa3SAbEI.js} +1 -1
- package/.output/public/assets/insights-C1uKy3p2.js +1 -0
- package/.output/public/assets/installations-Bj5AzZ3T.js +1 -0
- package/.output/public/assets/{installations-CE33wgbY.js → installations-DDTw9DSK.js} +2 -2
- package/.output/public/assets/{lazyRouteComponent-Ccp3bwBY.js → lazyRouteComponent-BN0MS05o.js} +1 -1
- package/.output/public/assets/{link-CI8idAUi.js → link-BG06whwB.js} +1 -1
- package/.output/public/assets/{loader-circle-BoxLrsOu.js → loader-circle-Bbp8LGVC.js} +1 -1
- package/.output/public/assets/{refresh-cw-Bqc72UZG.js → refresh-cw-DZgiAOMk.js} +1 -1
- package/.output/public/assets/routes-9E2okfpG.js +1 -0
- package/.output/public/assets/{routes-DGVXdzpw.js → routes-B3JviaDV.js} +2 -2
- package/.output/public/assets/{shield-check-uK3MHWJZ.js → shield-check-DoIiaaXU.js} +1 -1
- package/.output/public/assets/{signing-CMf4aqDO.js → signing-BKy9fIdB.js} +1 -1
- package/.output/public/assets/styles-C5-su40i.css +2 -0
- package/.output/public/assets/{table-2vcV89QB.js → table-bpgpQkPz.js} +1 -1
- package/.output/public/assets/useOpenInteractionType-4dPacbwb.js +1 -0
- package/.output/public/assets/x-Cr_sPM8E.js +1 -0
- package/.output/server/{__23tanstack-start-server-fn-resolver-B7ioJhtE.mjs → __23tanstack-start-server-fn-resolver-EMel9KYs.mjs} +36 -32
- package/.output/server/_libs/@base-ui/react+[...].mjs +1051 -1051
- package/.output/server/_libs/@reduxjs/toolkit+[...].mjs +1554 -0
- package/.output/server/_libs/@tanstack/react-router+[...].mjs +1 -1
- package/.output/server/_libs/@tanstack/react-router-ssr-query+[...].mjs +1 -1
- package/.output/server/_libs/d3-array.mjs +231 -0
- package/.output/server/_libs/d3-color.mjs +330 -0
- package/.output/server/_libs/d3-format.mjs +252 -0
- package/.output/server/_libs/d3-interpolate.mjs +207 -0
- package/.output/server/_libs/d3-path.mjs +77 -0
- package/.output/server/_libs/d3-scale+[...].mjs +1907 -0
- package/.output/server/_libs/d3-shape.mjs +818 -0
- package/.output/server/_libs/decimal.js-light.mjs +884 -0
- package/.output/server/_libs/es-toolkit.mjs +1159 -0
- package/.output/server/_libs/eventemitter3.mjs +236 -0
- package/.output/server/_libs/immer.mjs +532 -0
- package/.output/server/_libs/lucide-react.mjs +1 -1
- package/.output/server/_libs/next-themes.mjs +1 -1
- package/.output/server/_libs/react-redux+[...].mjs +205 -0
- package/.output/server/_libs/recharts+[...].mjs +14636 -0
- package/.output/server/_libs/sonner.mjs +1 -1
- package/.output/server/_libs/tanstack__react-query.mjs +1 -1
- package/.output/server/_runtime.mjs +10 -1
- package/.output/server/_ssr/InsightsErrorAlert-DzDVdfgW.mjs +169 -0
- package/.output/server/_ssr/{InsightsPageHeader-Df4Brj8u.mjs → InsightsPageHeader-CZ2AaQGk.mjs} +8 -120
- package/.output/server/_ssr/InsightsRolloutCard-DHB2iM4t.mjs +693 -0
- package/.output/server/_ssr/{api-keys-CZeTPQys.mjs → api-keys-BBl84_lN.mjs} +7 -7
- package/.output/server/_ssr/{api-keys-api-BUPQVSpk.mjs → api-keys-api-KSH04dLB.mjs} +2 -2
- package/.output/server/_ssr/{api-keys-rpc-Cs7tS5_j.mjs → api-keys-rpc-DBmgnW5o.mjs} +3 -3
- package/.output/server/_ssr/{api-rpc-tAupfGQm.mjs → api-rpc-DX5B6b-L.mjs} +25 -25
- package/.output/server/_ssr/{auth-rpc-DUaeOWKK.mjs → auth-rpc-gaibR719.mjs} +4 -4
- package/.output/server/_ssr/{auth.server-DW71WzsD.mjs → auth.server-DF_shCeq.mjs} +1 -1
- package/.output/server/_ssr/{bundle-signing-rpc-BhnBw0-d.mjs → bundle-signing-rpc-C01EKpb-.mjs} +2 -2
- package/.output/server/_ssr/{bundle-signing.server-C4NW5mWx.mjs → bundle-signing.server-Co_zHdJx.mjs} +1 -1
- package/.output/server/_ssr/{card-BRiPmi0C.mjs → card-gGh8F6kQ.mjs} +1 -1
- package/.output/server/_ssr/{config.server-afDuAwaG.mjs → config.server-NIXXHk-e.mjs} +5 -5
- package/.output/server/_ssr/{createSsrRpc-DjWNJ0uc.mjs → createSsrRpc-4rr4IJh3.mjs} +3 -3
- package/.output/server/_ssr/{deleteBundle-BTGwBgzx.mjs → deleteBundle-C71v1GBr.mjs} +1 -1
- package/.output/server/_ssr/{dialog-DS-nw-pA.mjs → dialog-DNtKYR5K.mjs} +1 -1
- package/.output/server/_ssr/{downloadBundle-CNnYxYO2.mjs → downloadBundle-A9zYC2go.mjs} +1 -1
- package/.output/server/_ssr/{getBundleChildren-C0vh4ZvE.mjs → getBundleChildren-CMGSGBWv.mjs} +1 -1
- package/.output/server/_ssr/{input-group-BpsnmvZw.mjs → input-group-Oj4kUnE4.mjs} +1 -1
- package/.output/server/_ssr/{insights-BdS364Xu.mjs → insights-oK2n5WBp.mjs} +13 -67
- package/.output/server/_ssr/insights-recovery-DSFM-YTL.mjs +21 -0
- package/.output/server/_ssr/insights-recovery-rpc-CgeCAs2K.mjs +16 -0
- package/.output/server/_ssr/{insights-rpc-38DujK0G.mjs → insights-rpc-DBf0ouen.mjs} +2 -2
- package/.output/server/_ssr/insightsRecovery-DFkXGFjd.mjs +108 -0
- package/.output/server/_ssr/{installations-CtA59MQG.mjs → installations-DBmYuVBi.mjs} +2 -2
- package/.output/server/_ssr/{installations-DiVKEF5T.mjs → installations-DQyJN-pZ.mjs} +9 -9
- package/.output/server/_ssr/{router-B9wG_9f8.mjs → router-C8J4nYEG.mjs} +13 -13
- package/.output/server/_ssr/{routes-CKrrS2q2.mjs → routes-C2RIYhyl.mjs} +2 -2
- package/.output/server/_ssr/{routes-D7Yh_jfj.mjs → routes-NlTRKPML.mjs} +21 -86
- package/.output/server/_ssr/{runtime.server-DacU1TBV.mjs → runtime.server-DLZpzphL.mjs} +1 -1
- package/.output/server/_ssr/{server-D_C2rMzf.mjs → server-hgrXpJQY.mjs} +3 -3
- package/.output/server/_ssr/{signing-Dz1WkZOf.mjs → signing-bfOvUMSJ.mjs} +3 -3
- package/.output/server/_ssr/ssr.mjs +1 -1
- package/.output/server/_ssr/{table-CnZmon_Z.mjs → table-QQ9cnWf2.mjs} +3 -3
- package/.output/server/_tanstack-start-manifest_v-BAb_a_eC.mjs +97 -0
- package/.output/server/index.mjs +152 -152
- package/package.json +5 -5
- package/src/components/features/insights/InsightsRolloutCard.tsx +505 -0
- package/src/components/features/releases/ReleaseEditorSheet.tsx +13 -0
- package/src/components/ui/empty.tsx +101 -0
- package/src/lib/insights-recovery-rpc.ts +14 -0
- package/src/lib/insights-recovery.ts +55 -0
- package/src/lib/server/insightsRecovery.ts +157 -0
- package/src/routes/insights.tsx +2 -0
- package/.output/public/assets/HashValueDisplay-CB0Eskit.js +0 -1
- package/.output/public/assets/InsightsPageHeader-Car5LNiZ.js +0 -1
- package/.output/public/assets/composite-Bp98A0Ld.js +0 -1
- package/.output/public/assets/createServerFn-CA8fs7QD.js +0 -3
- package/.output/public/assets/dialog-CUTrcPlB.js +0 -1
- package/.output/public/assets/index-DdAz-CXv.js +0 -10
- package/.output/public/assets/insights-C0PBSZ-n.js +0 -1
- package/.output/public/assets/installations-CStZja8x.js +0 -1
- package/.output/public/assets/routes-DTlwOr8I.js +0 -1
- package/.output/public/assets/styles-BCvbtKsm.css +0 -2
- package/.output/public/assets/useCompositeListItem-CHLITE_4.js +0 -1
- package/.output/public/assets/x-DCd0j8c6.js +0 -1
- package/.output/server/_ssr/HashValueDisplay-DwpGLfrl.mjs +0 -55
- package/.output/server/_tanstack-start-manifest_v-2MjTyxil.mjs +0 -97
- /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
|
+
}
|